Compare commits

...

80 Commits

Author SHA1 Message Date
Jeff Emmett 8683af03a1 fix: iframe embed for standalone rApps, all links use rspace.online/r*
Replaced the fetch-and-proxy approach (which caused CORS errors for
cross-origin assets, fonts, and service workers) with full-page iframe
embedding. Modules with standalone domains are embedded in an iframe
below the rSpace header. Modules without get a simple generated landing
page. All "Try Demo" and app switcher links now consistently use
rspace.online/{moduleId} instead of demo.rspace.online.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:47:15 -08:00
Jeff Emmett 833c6aaf66 refactor: remove rProviders from rApps, fix rSwag domain to rswag.online
rProviders (providers.mycofi.earth) is a separate project — removed
module registration, app switcher entry, tab bar badge, canvas embed
button, Traefik router, and standalone config. rSwag domain updated
from swag.mycofi.earth to rswag.online across all references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:39:43 -08:00
Jeff Emmett 2e67417ddc feat: add missing standalone domain routing for all rApps
Added standaloneDomain to rsplat module (rsplat.online). Added 8 missing
Traefik routers: rnotes, rfiles, rphotos, rinbox, rcart, rsplat,
swag.mycofi.earth, providers.mycofi.earth. All 22 standalone-domain
modules now have matching Traefik routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:34:25 -08:00
Jeff Emmett 09b5d1a3fa feat: proxy standalone domain landing pages for rspace.online/{moduleId}
Instead of the generated landing page, rspace.online/rnotes now fetches
and serves the real page from rnotes.online (with <base> tag for asset
resolution). 5-minute in-memory cache avoids repeated fetches. Falls
back to the generated landing page for modules without a standalone
domain or when the fetch fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:14:07 -08:00
Jeff Emmett fe6e46ca2c feat: app switcher links to landing pages, demo uses standalone rApp builds
App switcher on demo/bare domain now links to rspace.online/{moduleId}
landing pages instead of jumping straight to demo. "Try Demo" buttons
use standalone domain builds (rnotes.online, rvote.online, etc.) which
have better styling and more updated features. Falls back to
demo.rspace.online for modules without a standalone domain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:09:10 -08:00
Jeff Emmett c0ba24fcb9 feat: per-module landing pages on bare domain rspace.online/{moduleId}
Bare domain now serves a dedicated landing page for each rApp instead
of directly loading the demo. "Try Demo" links to demo.rspace.online
which loads the live app. Sub-paths still rewrite to demo for API compat.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:02:39 -08:00
Jeff Emmett c15fc15e36 fix: use bare domain rspace.online/r* for rApp links instead of personal subdomain
App switcher fallback was "personal" causing all rApp links to resolve
to personal.rspace.online/r*. Changed to "demo" so links use the bare
domain which the server rewrites to demo mode. Updated landing page
CTAs and ecosystem links to match.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:19:51 -08:00
Jeff Emmett 536b0c60f9 brand: add (you)r* prefix to landing page title
Reinforces the r-suite "your tools" philosophy across all rApp landing pages.
Also normalizes title separators to em-dash (—) for consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 04:21:51 +00:00
Jeff Emmett 77f9538a1a chore: update TASK-46 progress — postMessage bridge + module switcher
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:50:28 -08:00
Jeff Emmett 768ea19cfc feat: folk-rapp postMessage bridge, module switcher, open-in-tab
Enhance the folk-rapp canvas shape with three improvements:

1. PostMessage bridge: parent sends context to iframe on load,
   listens for shape-updated events from CommunitySync. Green
   status dot indicates active connection.

2. Module switcher: header dropdown (⇄ button) lets users change
   which rApp is embedded without recreating the shape.

3. Open-in-tab: ↗ button navigates to the module page (adds a tab)
   instead of opening a new browser window.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:50:07 -08:00
Jeff Emmett 50f0e11036 feat: folk-rapp shape — embed live rApp modules on the canvas
POC for cross-app embedding (TASK-46). New folk-rapp shape type that
embeds any rApp module as a live iframe inside a canvas shape. Features:
- Module picker dropdown when no module selected
- Colored header with module badge/icon
- Open-in-tab action button
- Syncs moduleId + spaceSlug via Automerge CRDT
- Toolbar rApps section now creates folk-rapp (not generic folk-embed)
- Fixed stale "canvas" moduleId refs → "rspace" in canvas.html

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:45:22 -08:00
Jeff Emmett e296824b7f chore: add backlog task TASK-HIGH.1 (bare-domain module routing)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:34:52 -08:00
Jeff Emmett a732478f85 feat: bare-domain module routing — rspace.online/{moduleId} as default
App dropdown links now go to rspace.online/r* (bare domain) instead of
demo.rspace.online/r*. Only the "Try Demo" button links to the explicit
demo subdomain. Server internally rewrites bare-domain module paths to
/demo/{moduleId} while preserving the browser URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:22:06 -08:00
Jeff Emmett 5ae8aeec02 feat: demo space improvements — description + sorted listing
- Add description to demo space seed for context in the UI
- Sort spaces API: user's own spaces first, then demo, then alphabetical

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:15:35 -08:00
Jeff Emmett 32160ae4ce feat: add "Try Demo" button to all headers + revert landing page copy
- Add teal "Try Demo" button to rstack-header (right side, before identity)
- Button links to demo.rspace.online/{currentModule} for context-aware demo entry
- Hidden when already on the demo space (server-side conditional)
- Revert website/index.html to original copy, only updating CTA link + adding demo button
- Add demo button CSS to shell.css

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:10:04 -08:00
Jeff Emmett 4895af19db feat: r-prefix module slugs, landing page, clickable rStack header
- Rename all 23 module IDs to r-prefixed slugs (canvas→rspace, notes→rnotes, etc.)
- Root rspace.online/ now serves the landing page instead of redirecting to demo
- rStack header in app switcher dropdown is now a clickable link to rstack.online
- Update all internal navigation links, badge maps, and URL helpers
- Space root redirects to /rspace instead of /canvas

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:04:22 -08:00
Jeff Emmett a2e3e2cb9c chore: add backlog tasks for canvas tab bar and persistence features
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:51:59 -08:00
Jeff Emmett c5295a94c9 chore: add backlog task TASK-63 (remove iframe shell)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:46:37 -08:00
Jeff Emmett c2729fb002 refactor: remove iframe shell — render all modules directly via web components
Every module except canvas was using renderIframeShell() to embed standalone
domains (rdata.online, rwork.online, etc.) via iframe. None of these domains
had independent deployments — they routed back to the same container, causing
infinite redirect loops or 404s.

Now all 22 modules render their web components directly inside renderShell(),
eliminating cross-origin failures, iframe loading spinners, and ~820 lines
of dead code. Standalone domain requests are internally rewritten to module
routes instead of 301 redirecting.

- Remove renderIframeShell(), renderStandaloneShell(), IframeShellOptions
- Remove keepStandalone set; rewrite standalone domains internally
- Convert all module GET / handlers to renderShell + <folk-*> components
- Delete 20 standalone.ts entry points (circular/broken)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:45:41 -08:00
Jeff Emmett fa6a2ce36b fix: tab bar persistence + iframe loading/error states
Tabs now persist in localStorage across page navigations so opening
a new rApp adds it alongside existing tabs instead of replacing them.
Iframe shell shows a loading spinner with 12s timeout and error panel
when standalone apps are unreachable. Converts rSwag to iframe shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:19:07 -08:00
Jeff Emmett 5c2c7f418b feat: canvas tab bar + rApps toolbar + iframe shell for all modules
Add the missing tab bar to the canvas page so users can switch between
rApp layers (with full CommunitySync persistence). Add an "rApps"
toolbar group that embeds any of the 18 remaining modules as interactive
iframes directly on the canvas. Switch all module page routes to
renderIframeShell, loading standalone domains inside the unified shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 18:03:29 -08:00
Jeff Emmett 340ae24f40 chore: add backlog tasks TASK-59 and TASK-60
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:45:04 -08:00
Jeff Emmett eab24e2e39 feat: canonical subdomain routing — {space}.rspace.online/{moduleId}
Consolidate URL routing so all rApps flow through
{space}.rspace.online/{moduleId} as the canonical URL pattern.

- Subdomain handler now routes all modules (not just canvas)
- Standalone domains (rvote.online etc) → 301 redirect to canonical
- Add shared/url-helpers.ts for subdomain-aware URL generation
- Update app-switcher, space-switcher, identity, tab-bar navigation
- Shell inline scripts use __rspaceNavUrl for all URL generation
- Path-based rspace.online/:space/:moduleId still works as fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:24:36 -08:00
Jeff Emmett eba0aafc6e feat: add rPhotos module + finish header standardization across all rApps
- Create modules/photos/ with Immich API proxy, gallery component,
  shared albums, lightbox viewer, and standard rapp-nav header
- Register photosModule in server/index.ts and add vite build step
- Fix remaining module headers: books (shelf + reader), splat, swag, tube
- All 23 modules now use consistent rapp-nav pattern — no branding headers
- Immich running at demo.rphotos.online, landing page at rphotos.online
- Add backlog tasks 53-58 for recent feature work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 16:06:17 -08:00
Jeff Emmett a5cd3c1322 refactor: standardize module component UI across all rApps
Consistent nav headers, button styles, and layout patterns
across calendar, cart, choices, data, forum, funds, inbox,
maps, network, notes, providers, trips, vote, and work modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:47:58 -08:00
Jeff Emmett 0075832093 feat: auto-route users to personal/demo space + landing overlay
- Anon users visiting any rApp (standalone or unified) land on demo space
- Logged-in users auto-redirect to personal space (auto-provisioned)
- POST /api/spaces/auto-provision creates personal space on first visit
- Standalone domains support /<space> path prefix (rpubs.online/jeff)
- rspace.online/ redirects to /demo/canvas (app-first experience)
- Quarter-screen welcome overlay on demo space for first-time visitors
- Full landing page moved to /about
- Auth flow triggers auto-space-resolution on sign-in/register
- Demo space seeded with shapes for all 22 rApps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:47:47 -08:00
Jeff Emmett cd440f1342 feat: layered tab system with inter-layer flows and bidirectional feeds
Introduces the full layer/tab architecture for rSpace — each rApp becomes
a layer in a vertical stack with typed flows (economic, trust, data,
attention, governance, resource) connecting them.

New components:
- rstack-tab-bar: tab bar with flat/stack view toggle, drag reorder,
  drag-to-connect flow creation with kind/label/strength dialog
- folk-feed: canvas shape that pulls live data from other layers with
  bidirectional write-back (edit items inline, push changes to source API)
- layer-types: Layer, LayerFlow, FlowKind types and color palette

Automerge schema extended with layers, flows, activeLayerId, layerViewMode.
CommunitySync gains 11 new methods for layer/flow CRUD.

Feed definitions added to 10 modules (funds, notes, vote, choices, wallet,
data, work, network, trips, canvas) with typed feeds and acceptsFeeds.

RSpaceModule interface extended with FeedDefinition and acceptsFeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:29:03 -08:00
Jeff Emmett 0813eed5e0 feat: consistent headers across all rApps + add mi AI assistant
Header consistency:
- Fix 52px → 56px header height in 7 module CSS files (pubs, funds,
  providers, books, swag, choices, cart)
- Remove custom header background overrides in books.css and pubs.css
- All pages now use the same 3-section header layout: left (app/space
  switchers), center (mi), right (identity)
- Add <rstack-mi> to all 4 standalone HTML pages (index, admin,
  create-space, canvas) and both shell renderers

mi AI assistant:
- New <rstack-mi> web component with search input "Ask mi anything..."
- Dropdown panel with streaming chat UI, typing indicator, markdown
  formatting
- POST /api/mi/ask endpoint: streams from Ollama with full rApp context
  in system prompt (all 22 modules, current space/module)
- Graceful fallback to keyword-based responses when Ollama unavailable
- Configurable via MI_MODEL and OLLAMA_URL env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 15:09:41 -08:00
Jeff Emmett 914d0e61c4 feat: wire up account settings endpoints (email, device, guardians)
Server (src/encryptid/server.ts):
- POST /api/account/email/start — send 6-digit verification code via SMTP
- POST /api/account/email/verify — verify code and set email on account
- POST /api/account/device/start — WebAuthn creation options for same-device
  passkey registration (authenticated, reuses existing userId)
- POST /api/account/device/complete — store additional credential under
  existing account

DB (src/encryptid/db.ts):
- Add 'device_registration' to StoredChallenge.type union
- Add 'email_verification' to StoredRecoveryToken.type union

Client (shared/components/rstack-identity.ts):
- Rewrite social recovery modal to use existing guardian API:
  GET /api/guardians, POST /api/guardians, DELETE /api/guardians/:id
- Loads existing guardians on open, adds/removes in real-time
- Shows guardian status (accepted/pending), invite emails sent on add
- Two name+email inputs (max 3 guardians, server-enforced)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:57:32 -08:00
Jeff Emmett 0647f19bd9 feat: redesign identity modal and space switcher UX
- Auth modal: unified "Sign up / Sign in" landing with stacked passkey buttons,
  close X button, and "Powered by EncryptID" link to ridentity.online
- Logged-in dropdown: replace Profile/Recovery (auth.ridentity.online) with
  Add Email, Add Second Device, Add Social Recovery settings modals
- Add Email: two-step flow (enter email → verify code)
- Add Second Device: WebAuthn credential registration for backup access
- Add Social Recovery: trusted contacts with configurable threshold
- Space switcher: emoji visibility badges (🔓 green / 🔑 yellow / 🔒 red),
  remove slash prefix, match app-switcher button styling
- Add rdata.online to standalone domain list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:50:35 -08:00
Jeff Emmett 53b4c44576 feat: update rstack-app-switcher categories
- rTube → Creating, rSwag stays in Creating
- Rename Social & Media → Sharing, split rData → Observing
- Add tube to Creating in MODULE_CATEGORIES and badge comments

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 14:17:30 -08:00
Jeff Emmett ef5dca5808 refactor: reorganize app-switcher categories — Sharing, Observing, move rTube to Creating
Rename "Social & Media" to "Sharing", split out rData into new "Observing"
category, and move rTube from Social to Creating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:30:44 -08:00
Jeff Emmett 11467b6758 feat: color-code space switcher by visibility and style as button
Space dropdown now shows visibility badges (PUBLIC/PRIVATE/PERMISSIONED)
with green/red/yellow color coding and left border accents. Trigger button
styled with background fill to match the rApps dropdown beside it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:27:26 -08:00
Jeff Emmett 5c3db2caea Redesign canvas toolbar with grouped dropdowns and collapse
28 flat tool buttons replaced with 6 category dropdowns (Create, Media,
Embed, AI, Travel, Decide) plus direct-access Connect/Memory/Zoom buttons.
Toolbar is now collapsible via a minimize toggle. Mobile responsive with
accordion-style groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 13:07:24 -08:00
Jeff Emmett 7937066486 Standardize emojis across header, favicons, and ecosystem links
Canonical emoji set for consistency:
- 🕸️ rNetwork (was 🌐), ⚖️ rChoices (was 🔀), 📋 rWork (was 💼)
- 🔐 rIdentity (was 🔑), 📖 rPubs (was 📰), 💸 rFunds (was 💰)
- 💰 rWallet (was 💼 in footer), 📬 rInbox (was ✉️)
- 💭 rForum (was 💬, differentiates from rChats)
- 📢 rSocials (was 📱), 🔨 rAuctions (was 🏛️ in footer)
- 🎬 rTube (was 📹 in footer)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:57:14 -08:00
Jeff Emmett b0c7c48376 Update rNetwork emoji to 🕸️ in ecosystem links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:50:50 -08:00
Jeff Emmett 131f64abe3 Replace 🌌 emoji favicon with [rS] logo on all pages
Use the actual favicon.png ([rS] brand mark) instead of the galaxy emoji
across index, create-space, and admin pages to match canvas.html.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 12:39:56 -08:00
Jeff Emmett bcef750249 feat: change 2-finger gestures from zoom to pan on canvas
Two-finger touch and trackpad scroll now pan instead of zoom.
Zoom is still available via Ctrl+wheel or trackpad pinch (ctrlKey).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:52:15 -08:00
Jeff Emmett 900eac2e21 feat: notes module reads from Automerge docs via WebSocket sync (Tier 1)
Notebook detail view now subscribes to Automerge docs instead of REST,
enabling real-time sync across tabs. Note creation and editing use
Automerge.change() with debounced sync. REST fallback after 5s timeout.
Notebook list and search remain REST-based.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 08:32:37 +00:00
Jeff Emmett 5b3c41c559 Add internal provision endpoint for rSpace Registry
Auth-free POST /api/internal/provision creates community via Hono route,
triggers onSpaceCreate for all registered modules.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 00:30:22 -08:00
Jeff Emmett 07a7a083f4 feat: standardize AppSwitcher and EcosystemFooter across all rApps
- Update AppSwitcher with all 26 r*Apps in 8 categories
- Add EcosystemFooter component with consistent ecosystem links
- Categories: Creating, Planning, Communicating, Deciding,
  Funding & Commerce, Social & Media, Work & Productivity,
  Identity & Infrastructure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:47:54 -08:00
Jeff Emmett be1067bed9 refactor: migrate EncryptID URLs from encryptid.jeffemmett.com to auth.ridentity.online
Part of the ridentity.online branding migration. The EncryptID auth
server is now accessible at auth.ridentity.online (with the legacy
encryptid.jeffemmett.com kept as a backward-compatible alias).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:22:15 -08:00
Jeff Emmett f8f7889bd7 feat: run PG→Automerge migration — 19 docs, 292 rows, 0 errors
Added run-migration.ts script and getDocIds() method on SyncServer.
All 11 module adapters ran successfully against live demo data.
Docs persisted to /data/docs/, backups to /data/docs-backup/.
Idempotent: re-runs skip existing docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 06:10:51 +00:00
Jeff Emmett ca3e8f42ab feat: add auth.ridentity.online as EncryptID route alias
Add ridentity.online and auth.ridentity.online to Traefik router rules,
WebAuthn Related Origins, and CORS allowed origins. This enables the
ridentity.online domain to serve as the branded identity layer while
keeping the RP ID on rspace.online (no passkey breakage).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:47:47 -08:00
Jeff Emmett 3d5a142062 feat: wire DocSyncManager into rSpace WebSocket server
Protocol multiplexing on existing /ws/{slug} endpoint:
- Messages with docId (subscribe/unsubscribe/sync/awareness) → SyncServer
- Messages without docId → legacy canvas handlers (unchanged)

New files: doc-persistence.ts (debounced Automerge save/load),
sync-instance.ts (SyncServer singleton with participant mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 05:19:21 +00:00
Jeff Emmett 1cb361637d fix: switch all module shell themes from light to dark
The light theme was causing the header bar to render with a white
background, clashing with the dark-themed module content underneath.
All modules and both shell renderers now default to dark theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:42:33 -08:00
Jeff Emmett 073a444c04 feat: rebrand AppSwitcher with pastel badges, rStack header, updated categories
- Pastel rainbow badges (rS, rN, rP, rC, rT...) replace plain emoji icons
- Emoji moved to right of app name in dropdown items
- rStack header with gradient badge at top of dropdown
- rStack footer link at bottom
- Canvas renamed to rSpace
- rMaps moved to Planning category
- "Sharing & Media" renamed to "Social & Sharing" with rNetwork at top
- Trigger button shows pastel badge + app name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:36:58 -08:00
Jeff Emmett 2468177c26 feat: add folk-canvas shape, WS cascade enforcement, and at-rest encryption
Phase 3: folk-canvas nested space renderer with live WS connection,
auto-scaling viewport, collapsed/expanded views, permission badges.

Phase 4: WS cascade permission enforcement — nest filter on broadcasts,
addShapes/deleteShapes checks, readOnly enforcement for nested connections.

Phase 5: AES-256-GCM at-rest encryption for Automerge documents with
transparent encrypt-on-save/decrypt-on-load and API toggle endpoints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:43:30 -08:00
Jeff Emmett 183089d6d8 feat: implement nested spaces architecture with permission cascade
Spaces are now nestable — any space can embed references to other spaces
via SpaceRef, with a permission cascade model (most-restrictive-wins at
each nesting boundary). Every EncryptID registration auto-provisions a
sovereign space at <username>.rspace.online with consent-based nesting
controls.

Key additions:
- NestPolicy per space (open/members/approval/closed consent levels)
- SpaceRef CRUD with allowlist/blocklist, permission ceiling enforcement
- Approval flow for nest requests with admin review
- Reverse lookup (nested-in) so owners see where their space appears
- Source space admins can always revoke (sovereignty guarantee)
- cascadePermissions() for multi-depth permission intersection
- Client-side types for nested space rendering
- Full spec at docs/SPACE-ARCHITECTURE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:27:10 -08:00
Jeff Emmett de0835c4d8 feat: rFunds landing page overhaul with EncryptID auth and space-scoped flows
Enhanced landing page with gradient hero, 5-card features grid, auth-aware
"Your Flows" section, and 3-step "How TBFF Works" walkthrough. Added
EncryptID token verification on flow creation, space_flows DB table for
per-space flow association, and space-scoped flow listing API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 02:19:23 +00:00
Jeff Emmett 470895c6a8 fix: serve /admin directly in fetch handler to bypass /:space catch-all
Hono's parameterized /:space route was capturing /admin before the
explicit route. Move admin.html serving into the Bun.serve fetch
handler so it runs before Hono routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:34:36 -08:00
Jeff Emmett 033f4a3d92 feat: add admin dashboard at /admin with space overview
Adds a new /admin page showing all spaces with stats (shape count,
member count, file size, visibility), search/filter/sort controls,
and links to open or export each space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:29:15 -08:00
Jeff Emmett b2f1bebbd2 fix: resolve all TypeScript build errors across modules
- Change sql.unsafe() param arrays from unknown[] to any[] (9 modules)
- Exclude sw.ts and demo-sync.ts from tsconfig (separate build targets)
- Add type stub for @mkkellogg/gaussian-splats-3d (CDN-loaded)
- Rename private title → designTitle in folk-swag-designer (HTMLElement conflict)
- Fix Hono context typing and instanceof cast in splat module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:42:44 -08:00
Jeff Emmett 772726f420 feat: wire rspace to pull secrets from Infisical at startup
Add entrypoint.sh that authenticates with Infisical via universal-auth
and injects secrets as env vars before starting the Bun server.
Uses Bun's built-in fetch API instead of Node.js http module.

Secrets removed from docker-compose.yml (now fetched at runtime):
INTERNAL_API_KEY, HETZNER_API_TOKEN, CLOUDFLARE_API_TOKEN,
CLOUDFLARE_ZONE_ID, TWENTY_API_TOKEN, R2_*, X402_*, SMTP_PASS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:36:27 -08:00
Jeff Emmett 5b5c0be732 feat: restructure rFunds — landing page + multi-view TBFF flow
rfunds.online now shows a landing page with TBFF info and flow list
instead of the river demo. The river is one tab in a 3-tab flow detail
view (Table | River | Transactions).

- Add folk-funds-app.ts: main app component with landing + detail views
- Extract mapFlowToNodes to shared lib/map-flow.ts
- Simplify folk-budget-river.ts to pure renderer (no API fetching)
- Restructure routes: / = landing, /demo = demo detail, /flow/:id = flow
- Expand funds.css for landing, tabs, table cards, transaction list
- Add folk-funds-app.ts build entry to vite.config.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:18:01 -08:00
Jeff Emmett ae8d306a62 feat: scrollable categorized rApp dropdown, rSpaces rename, overlap prevention
- rApp dropdown now scrollable (max-height: 70vh) with apps grouped into
  5 categories: Creating, Planning, Discussing & Deciding, Funding &
  Commerce, Sharing & Media
- Renamed canvas object terminology from "shapes" to "rSpaces" in UI labels
- New shapes placed on canvas automatically find free positions using
  spiral search to avoid overlapping existing shapes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 16:20:41 -08:00
Jeff Emmett 05dfa6d3a0 Add migration dry-run script and standardize space slugs
dry-run.ts validates all 11 adapters against live DB (19 docs, 292 rows,
0 errors). Standardized rwork slug rspace-dev→demo and rvote slug
community→demo so all seeded data uses consistent space identifier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:46:28 +00:00
Jeff Emmett 3a0d376f23 Add 7-layer local-first data infrastructure
Crypto (PRF/HKDF/AES-256-GCM per-doc keys), Document (schema + manager),
Storage (encrypted IndexedDB), Sync (multi-doc WebSocket client + server),
Compute (local/server-delegated transforms), Query (views + search),
and Memory Card interchange format. 2919 lines across 10 files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:12:06 +00:00
Jeff Emmett b412063684 Fix 4 migration adapters, add 7 new ones, add safety features
Notes/work/cal/vote adapters had wrong table names and missing fields.
Now match actual PG schemas. Added books, cart, providers, files, trips,
inbox, splat adapters. Engine gains idempotency, dry-run, disk backup,
per-row error recovery, and progress logging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:05:14 +00:00
Jeff Emmett 4cd985c29a Remove rnotes.online routing — re-deployed standalone with Memory Card spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:53:11 +00:00
Jeff Emmett 4afce2dc37 Add sample splats + photo/video upload pipeline for rSplat
Seed gallery with real Bonsai and Nike Shoe splat scenes from Hugging Face,
replacing the synthetic Rainbow Sphere. Add photo/video upload endpoint
(POST /api/splats/from-media) with processing status tracking for future
COLMAP + OpenSplat generation. Gallery now shows upload mode toggle
(splat file vs photos/video) and processing status overlays on cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:27:41 +00:00
Jeff Emmett 3c986f1709 Add standalone docker-compose for all 20 modules
Each module can run as an independent container using its standalone.ts
entrypoint. Reuses the same rspace-online image with CMD override.

Usage:
  docker compose -f docker-compose.yml -f docker-compose.standalone.yml \
    up -d rtrips-standalone

All services share rspace-db and traefik-public. Module-specific deps
mapped: books/files/swag volumes, OSRM for trips, IMAP for inbox,
R2 for tube, payment network for cart/funds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:07:09 +00:00
Jeff Emmett d25bb3ec5e Route 15 standalone domains through rSpace unified server
Add domain→module rewrite in Bun.serve fetch handler for standalone
domains (rbooks, rpubs, rchoices, rfunds, rforum, rvote, rnotes, rwork,
rcal, rtrips, rwallet, rdata, rnetwork, rtube, rmaps). Requests to
these domains get rewritten to /demo/{moduleId}/... and served by the
existing Hono module routes.

Adds Traefik labels at priority 120 for all 15 domains. Keeps rcart,
rfiles, swag, and providers on their own containers.

This retires ~25 legacy containers, freeing significant memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:44:52 +00:00
Jeff Emmett 095d6e1eb9 feat: add newsletter signup section to landing page
Adds a Listmonk-powered newsletter form at the bottom of the rSpace
landing page, matching the dark theme and gradient styling of existing
sections. Uses rSpace list UUID for subscriber routing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:37:38 -07:00
Jeff Emmett 91d600ed93 Fix rSplat viewer: add file extension to URL for format detection
GaussianSplats3D infers format from URL file extension. Changed file
serve route to /api/splats/:id/:filename so URLs end in .splat/.ply/.spz.
Also fixed camera orientation (Y-up) and scale values in test data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:31:46 +00:00
Jeff Emmett e703405f68 Move conic trajectory visualization into rTrips as route planner
Relocate the conic intersection calculator from standalone rConic module
into rTrips at /routes sub-page. Adds enhanced visualization with ghost
conic curves, distinct arc colors, overlap zones, crossing angle lines,
pulsing intersection markers, arc labels, and SVG legend.

- New math: sampleConicCurve() for ghost curve tracing, conicTangentAt()
  for tangent/crossing angle computation
- Route planner served at /{space}/trips/routes with OSRM proxy
- Removed standalone conic module registration from server/index.ts
- Updated vite build config to build folk-route-planner under trips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 00:10:52 +00:00
Jeff Emmett f3314477d8 Separate landing page from space creation
Move the create-space form out of the landing page into its own
route at /create-space. Landing page now shows CTA buttons for
"Create a Space" and "Try the Demo". /new redirects to /create-space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:55:25 +00:00
Jeff Emmett c99c25f174 Add rSplat module — Gaussian splat viewer with x402 gated uploads
New rSpace module for 3D Gaussian splat viewing. Gallery + full-viewport
Three.js/GaussianSplats3D viewer loaded via CDN importmap. EncryptID auth
on uploads, optional x402 micro-transaction gate. Reusable x402 Hono
middleware in shared/x402/.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 20:55:45 +00:00
Jeff Emmett cf3be7d7a9 Wire real auth and backends into all 21 rSpace modules
- Auth: 10 modules now use EncryptID token verification on write
  routes (vote, inbox, forum, files, notes, work, cal, trips,
  cart, providers). All POST/PUT/DELETE without valid token → 401.
- Tube: S3Client for Cloudflare R2 bucket (upload, streaming, range)
- Data: Umami analytics proxy (stats, active, tracker, events)
- Wallet: Safe Global API proxy with 12 chains + cache headers
- Network: Twenty CRM GraphQL client (people, companies, graph)
- Maps: sync-url endpoint for WebSocket connection
- Inbox: background IMAP sync worker (30s poll via ImapFlow)
- Forum: provisioner already wired (Hetzner + Cloudflare + cloud-init)
- Config: .gitignore fix, docker-compose env vars + rmail network,
  added @aws-sdk/client-s3, imapflow, mailparser deps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 19:37:26 +00:00
Jeff Emmett 2015a9c277 Include all apps with standalone domains and user-accessible spaces
App switcher:
- Add standaloneDomain to ModuleInfo (exposed via /api/modules)
- Add missing standaloneDomain to 5 modules: funds, files, wallet,
  choices, forum (20/21 modules now have standalone domains)
- Show external link arrow on hover for each app's standalone site

Space switcher:
- Pass auth token when fetching /api/spaces so the API returns
  private (authenticated/members_only) spaces the user owns or
  is a member of
- Group spaces into "Your spaces" (with role badge) and "Public
  spaces" sections
- Reload space list on auth-change (sign-in/sign-out)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:44:12 +00:00
Jeff Emmett 69d382cb70 Add app/space switcher dropdowns to landing and canvas pages
Replace legacy mountHeader() (identity only) with the full rstack
shell header on both index.html and canvas.html. Both pages now
show rstack-app-switcher (21 modules), rstack-space-switcher, and
rstack-identity — matching the module pages rendered by renderShell().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:04:23 +00:00
Jeff Emmett fa740e09ca feat: add 13 new modules — complete r-suite platform unification
Port all remaining r-suite apps as rSpace modules:

Batch 1: vote, notes, maps, wallet
- rVote: conviction voting engine with quadratic credit costs
- rNotes: markdown note-taking with notebooks and tags
- rMaps: interactive map viewer with markers and routes
- rWallet: multi-chain crypto wallet viewer (Safe Global API)

Batch 2: work, trips, cal, network
- rWork: kanban board with configurable status columns
- rTrips: trip planner with destinations, itinerary, expenses, packing
- rCal: calendar with lunar phase computation and cross-module linking
- rNetwork: graph visualization placeholder (Automerge + CRM proxy)

Batch 3: tube, inbox, data
- rTube: video library + HLS live streaming (Cloudflare R2)
- rInbox: collaborative email with multisig approval workflow
- rData: privacy-first analytics dashboard (Umami proxy)

All 21 modules registered, built, deployed, and verified at
rspace.online/demo/{moduleId}. Each module has:
- mod.ts (Hono routes + RSpaceModule export)
- standalone.ts (independent deployment)
- folk-* web component (Shadow DOM, no framework)
- CSS + DB schema (where needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:59:00 +00:00
Jeff Emmett 682c995cc3 feat: add files and forum modules — Phase 7+8
Files module (rFiles): file upload/download, share links with
expiry/password/download limits, memory cards CRUD, access logging,
cleanup timers replacing Django Celery tasks.

Forum module (rForum): Discourse cloud provisioner with Hetzner VPS
creation, Cloudflare DNS, cloud-init for automated Discourse install,
async provisioning pipeline with step logging, instance management.

All 10 modules now active: Canvas, rBooks, rPubs, rCart, Providers,
Swag, rChoices, rFunds, rFiles, rForum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:25:18 +00:00
Jeff Emmett 92edaaed45 feat: add choices and funds modules — Phase 5+6
Phase 5 — Choices: Lightweight module wrapping the canvas-native
folk-choice-vote/rank/spider components. Lists choice shapes from the
space's Automerge doc, links to canvas for creation/interaction.

Phase 6 — Funds: Port of rfunds-online BudgetRiver visualization to
vanilla web component. Includes simulation engine (pure functions),
types (stripped @xyflow), demo presets, and SVG sankey river with
animated waterfalls, overflow branches, and sufficiency badges.
Flow-service API proxy for same-origin frontend calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 23:11:48 +00:00
Jeff Emmett d9cc86637c feat: add cart, providers, swag modules — Phase 4 cosmolocal commerce
Port the three cosmolocal print-on-demand services (rCart, Provider Registry,
Swag Designer) into the unified rSpace module system. Each module includes
Hono API routes, folk-* web components, DB schemas, and standalone servers.

- Cart: catalog ingest, orders with state machine, x402 detection, rFunds flow deposit
- Providers: 6 seeded providers, earthdistance proximity queries, capability matching
- Swag: product templates (sticker/poster/tee), Sharp image processing, artifact envelopes
- DB: cube + earthdistance extensions, rcart + providers schemas
- Docker: swag-artifacts volume, payment-network for flow service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:59:19 +00:00
Jeff Emmett f313d9395d fix(encryptid): allow self-signed TLS for internal SMTP
Mailcow at mail.rmail.online uses a self-signed certificate.
Set tls.rejectUnauthorized: false for the SMTP transport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:41:46 +00:00
Jeff Emmett ca5c1363ce feat: add pubs module — Phase 3 port of rPubs to rSpace platform
Port pocket-press (Next.js + Typst) as an rSpace module with Hono routes,
vanilla folk-pubs-editor web component, and Typst v0.13.1 for PDF generation.
Includes all 4 format templates (A7, A6, Quarter Letter, Digest), artifact
envelope creation with cosmolocal spec, and standalone deployment support.

Typst binary installed in Docker via multi-stage build from debian:bookworm-slim.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:41:10 +00:00
Jeff Emmett 88de4c30dd feat(encryptid): guardian recovery, device linking, enhanced profile
Add 2-of-3 guardian recovery system:
- Guardian invite via email or shareable link
- One-click approval page for recovery requests
- Social recovery initiation (anti-enumeration)
- 7-day recovery request expiry

Add second device linking:
- QR code + link for cross-device passkey registration
- 10-minute link expiry, one-time use

Enhanced profile page:
- Account security checklist (email, device, guardians)
- Guardian management (add/remove, max 3)
- Device linking with QR code display
- Recovery initiation form for lost devices

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:07:40 +00:00
Jeff Emmett b42179cff7 feat: add books module — Phase 2 port of rBooks to rSpace platform
Port rbooks-online (Next.js + React) as an rSpace module with Hono routes,
vanilla web components, and shared PostgreSQL schema. Includes library grid
(folk-book-shelf), flipbook PDF reader (folk-book-reader), upload with
EncryptID auth, IndexedDB caching, and standalone deployment support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:07:34 +00:00
Jeff Emmett eed7b2f151 feat: unified module system — Phase 0 shell + Phase 1 canvas module
Implement the rSpace module architecture that enables all r-suite apps
to run as modules within a single-origin platform at rspace.online,
while each module can still deploy standalone at its own domain.

Phase 0 — Shell + Module System:
- RSpaceModule interface (shared/module.ts) with routes, metadata, hooks
- Shell HTML renderer (server/shell.ts) for wrapping module content
- Three header web components: rstack-app-switcher, rstack-space-switcher,
  rstack-identity (refactored from rspace-header.ts into Shadow DOM)
- Space registry API (server/spaces.ts) — /api/spaces CRUD
- Hono-based server (server/index.ts) replacing raw Bun.serve fetch handler
  while preserving all WebSocket, API, and subdomain backward compat
- Shared PostgreSQL with per-module schema isolation (rbooks, rcart, etc.)
- Vite multi-entry build: shell.js + shell.css built alongside existing entries
- Module info API: GET /api/modules returns registered module metadata

Phase 1 — Canvas Module:
- modules/canvas/mod.ts exports canvasModule as first RSpaceModule
- Canvas routes mounted at /:space/canvas with shell wrapper
- Fallback serves existing canvas.html for backward compatibility
- /:space redirects to /:space/canvas

URL structure: rspace.online/{space}/{module} (e.g. /demo/canvas)
All existing subdomain routing (*.rspace.online) preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 21:54:15 +00:00
219 changed files with 45831 additions and 843 deletions

1
.gitignore vendored
View File

@ -6,6 +6,7 @@ dist/
# Data storage
data/
!modules/data/
# IDE
.vscode/

View File

@ -8,7 +8,7 @@ COPY package.json bun.lock* ./
# Copy local SDK dependency (package.json references file:../encryptid-sdk)
COPY --from=encryptid-sdk . /encryptid-sdk/
RUN bun install --frozen-lockfile
RUN bun install
# Copy source
COPY . .
@ -16,31 +16,59 @@ COPY . .
# Build frontend (skip tsc in Docker — type checking is done in CI/local dev)
RUN bunx vite build
# Typst binary stage — download once, reuse in production
FROM debian:bookworm-slim AS typst
RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates \
&& curl -fsSL https://github.com/typst/typst/releases/download/v0.13.1/typst-x86_64-unknown-linux-musl.tar.xz \
-o /tmp/typst.tar.xz \
&& tar xf /tmp/typst.tar.xz -C /tmp \
&& mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst \
&& rm -rf /tmp/typst* \
&& chmod +x /usr/local/bin/typst
# Production stage
FROM oven/bun:1-slim AS production
WORKDIR /app
# Install Typst binary (for rPubs PDF generation)
COPY --from=typst /usr/local/bin/typst /usr/local/bin/typst
# Copy built assets and server
COPY --from=build /app/dist ./dist
COPY --from=build /app/server ./server
COPY --from=build /app/lib ./lib
COPY --from=build /app/shared ./shared
COPY --from=build /app/modules ./modules
COPY --from=build /app/package.json .
COPY --from=build /encryptid-sdk /encryptid-sdk
# Install production dependencies only
RUN bun install --production --frozen-lockfile
RUN bun install --production
# Create data directory
RUN mkdir -p /data/communities
# Create data directories
RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats
# Copy entrypoint for Infisical secret injection
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Set environment
ENV NODE_ENV=production
ENV STORAGE_DIR=/data/communities
ENV BOOKS_DIR=/data/books
ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts
ENV FILES_DIR=/data/files
ENV SPLATS_DIR=/data/splats
ENV PORT=3000
# Data volume for persistence
# Data volumes for persistence
VOLUME /data/communities
VOLUME /data/books
VOLUME /data/swag-artifacts
VOLUME /data/files
VOLUME /data/splats
EXPOSE 3000
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["bun", "run", "server/index.ts"]

View File

@ -0,0 +1,8 @@
---
id: m-0
title: "Canvas → rSpace Migration"
---
## Description
Complete migration of all custom shapes and functionality from canvas-website (tldraw) to rspace-online (Web Components). rspace-online replaces canvas-website as the production platform.

View File

@ -0,0 +1,8 @@
---
id: m-1
title: "rSpace App Ecosystem"
---
## Description
Transform rSpace from a flat shape canvas into a composable app ecosystem with data pipes, event broadcasting, semantic grouping, shape nesting, and cross-app embedding from the r-ecosystem (rWallet, rVote, rMaps, etc.).

View File

@ -0,0 +1,44 @@
---
id: TASK-21
title: Offline-first support with IndexedDB persistence and Service Worker
status: Done
assignee: []
created_date: '2026-02-18 19:39'
labels:
- feature
- offline
- infrastructure
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Added full offline-first capability to rSpace apps. Automerge documents and sync state now persist to IndexedDB via a new OfflineStore class, enabling instant load from cache, offline editing, and automatic incremental merge on reconnect. A Service Worker caches the app shell (HTML/JS/WASM) for loading without network.
## Changes
- **lib/offline-store.ts** (new): IndexedDB wrapper storing Automerge doc binary + SyncState per community slug, with debounced saves
- **lib/community-sync.ts**: Integrated offline persistence — initFromCache(), #scheduleSave(), #persistSyncState(), saveBeforeUnload(), infinite reconnect with capped backoff
- **website/sw.ts** (new): Service Worker — cache-first for hashed assets, network-first for HTML, skip WS/API
- **website/canvas.html**: SW registration, OfflineStore init, offline/online status UI, beforeunload save
- **vite.config.ts**: build-sw plugin to produce dist/sw.js without content hash
- **tsconfig.json**: Excluded sw.ts from main typecheck (WebWorker types)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 IndexedDB persists Automerge doc binary per community slug
- [ ] #2 Cached content loads instantly on page refresh (before WebSocket connects)
- [ ] #3 Offline editing works — shapes can be created/moved/deleted without network
- [ ] #4 Changes merge automatically on reconnect via Automerge sync protocol
- [ ] #5 Service Worker caches HTML/JS/WASM for offline app shell loading
- [ ] #6 Reconnect retries indefinitely (30s max backoff) when offline store is present
- [ ] #7 beforeunload saves pending changes immediately
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented offline-first support for rSpace. Created OfflineStore (IndexedDB wrapper) and Service Worker. Integrated into CommunitySync with debounced saves after every doc mutation, SyncState persistence for incremental reconnect, and infinite retry with capped backoff. Canvas UI shows offline/online status. Build produces dist/sw.js alongside main app. Pushed to Gitea main branch (6b06168).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,24 @@
---
id: TASK-22
title: Add offline-first section to rSpace landing page
status: Done
assignee: []
created_date: '2026-02-18 19:41'
labels:
- website
- content
dependencies: []
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Added an "Offline-First" feature card to the hero grid and a dedicated content section on the rSpace.online landing page explaining local IndexedDB persistence, Automerge auto-merge, and incremental sync. Matches existing visual style with pillars and identity cards.
<!-- SECTION:DESCRIPTION:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added 4th feature card (Offline-First) to hero grid, updated grid to 4 columns (2 on mobile), and added a new section with 3 pillars (Local Persistence, Auto-Merge, Incremental Sync) plus a "How it works" explanation card. Pushed to Gitea main (ea8f1b3).
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,43 @@
---
id: TASK-23
title: 'Feature parity audit: 13 overlapping shapes'
status: To Do
assignee: []
created_date: '2026-02-18 19:49'
labels:
- audit
- phase-0
milestone: m-0
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Compare each of the 13 shapes that exist in both canvas-website (tldraw) and rspace-online (folk-*) side-by-side. Document missing features in the rspace versions.
Pairs to audit:
1. ChatBox ↔ folk-chat
2. VideoChat ↔ folk-video-chat
3. Embed ↔ folk-embed
4. Markdown ↔ folk-markdown
5. Slide ↔ folk-slide
6. Prompt ↔ folk-prompt
7. ObsNote ↔ folk-obs-note
8. Transcription ↔ folk-transcription
9. ImageGen ↔ folk-image-gen
10. VideoGen ↔ folk-video-gen
11. GoogleItem ↔ folk-google-item
12. Map ↔ folk-map
13. WorkflowBlock ↔ folk-workflow-block
For each pair: read both implementations, note feature gaps, classify as critical/nice-to-have.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All 13 shape pairs compared side-by-side
- [ ] #2 Feature gaps documented with severity (critical/nice-to-have)
- [ ] #3 Critical gaps identified for immediate fix
<!-- AC:END -->

View File

@ -0,0 +1,31 @@
---
id: TASK-24
title: Add infrastructure dependencies for shape migration
status: To Do
assignee: []
created_date: '2026-02-18 19:49'
labels:
- infrastructure
- phase-1
milestone: m-0
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Install npm dependencies required by shapes being ported from canvas-website:
- h3-js (HolonShape geospatial)
- @xterm/xterm + @xterm/addon-fit (Multmux terminal)
- safe-apps-sdk or ethers (TransactionBuilder, if needed)
Also verify existing deps like perfect-freehand are sufficient for Drawfast.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All required npm packages installed
- [ ] #2 No build errors after adding dependencies
- [ ] #3 WASM plugins configured if needed (h3-js)
<!-- AC:END -->

View File

@ -0,0 +1,38 @@
---
id: TASK-25
title: Add server API proxy endpoints for new shapes
status: To Do
assignee: []
created_date: '2026-02-18 19:49'
labels:
- infrastructure
- phase-1
- server
milestone: m-0
dependencies: []
references:
- rspace-online/server/index.ts
- canvas-website/src/shapes/ImageGenShapeUtil.tsx (API pattern reference)
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add API routes to server/index.ts for shapes that call external services:
- POST /api/blender-gen — Proxy to Blender generation service
- POST /api/mycrozine — Proxy for zine generation LLM calls
- POST /api/fathom/* — Proxy to Fathom API (meeting transcripts)
- POST /api/obsidian/* — Local Obsidian vault file operations
- POST /api/holon/* — HoloSphere network queries
- WebSocket endpoint for terminal sessions (Multmux)
Follow existing pattern from /api/image-gen endpoint.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All proxy endpoints return expected responses
- [ ] #2 WebSocket terminal endpoint accepts connections
- [ ] #3 Error handling and auth middleware applied
<!-- AC:END -->

View File

@ -0,0 +1,46 @@
---
id: TASK-26
title: Port folk-blender-gen shape (3D procedural generation)
status: To Do
assignee: []
created_date: '2026-02-18 19:49'
labels:
- shape-port
- phase-2
- ai
milestone: m-0
dependencies:
- TASK-24
- TASK-25
references:
- canvas-website/src/shapes/BlenderGenShapeUtil.tsx
- rspace-online/lib/folk-image-gen.ts (pattern reference)
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port BlenderGenShapeUtil from canvas-website to rspace-online as a FolkShape Web Component.
Source: canvas-website/src/shapes/BlenderGenShapeUtil.tsx (693 lines)
Target: rspace-online/lib/folk-blender-gen.ts
Features to implement:
- Blender script editor textarea
- LLM code generation (prompt → Blender Python script)
- 3D preview iframe/canvas
- Model download button
- Loading/error states
Follow folk-image-gen.ts pattern for API-calling shape with loading/result states.
Needs /api/blender-gen server endpoint (TASK-25).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Shape renders with script editor and 3D preview
- [ ] #2 LLM code generation produces valid Blender scripts
- [ ] #3 Results sync across clients via Automerge
- [ ] #4 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,36 @@
---
id: TASK-27
title: Port folk-mycrozine-template shape (zine template picker)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-2
- ai
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/shapes/MycrozineTemplateShapeUtil.tsx
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port MycrozineTemplateShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/MycrozineTemplateShapeUtil.tsx (80 lines)
Target: rspace-online/lib/folk-mycrozine-template.ts
Features: Template gallery picker, preview panel, template selection that launches MycroZineGenerator.
Small shape — mostly a launcher UI for the generator.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Template gallery displays available templates
- [ ] #2 Selecting a template creates/configures a MycroZineGenerator shape
- [ ] #3 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,45 @@
---
id: TASK-28
title: Port folk-mycrozine-gen shape (AI zine generator)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-2
- ai
milestone: m-0
dependencies:
- TASK-24
- TASK-25
references:
- canvas-website/src/shapes/MycroZineGeneratorShapeUtil.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port MycroZineGeneratorShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/MycroZineGeneratorShapeUtil.tsx (1,222 lines)
Target: rspace-online/lib/folk-mycrozine-gen.ts
Features to implement:
- Prompt input for zine content
- Style and layout settings UI
- AI-powered multi-page zine generation via LLM
- Page preview/navigation
- Export/download functionality
Largest AI shape to port. Needs /api/mycrozine server endpoint (TASK-25).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Prompt input generates zine pages via LLM
- [ ] #2 Multi-page navigation works
- [ ] #3 Style/layout settings affect output
- [ ] #4 Results sync across clients via Automerge
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,41 @@
---
id: TASK-29
title: Port folk-drawfast shape (collaborative drawing/gesture recognition)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-2
- creative
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/shapes/DrawfastShapeUtil.tsx
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port DrawfastShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/DrawfastShapeUtil.tsx (652 lines)
Target: rspace-online/lib/folk-drawfast.ts
Features to implement:
- Freehand sketch input canvas
- Gesture recognition (circles, lines, rectangles, arrows)
- Shape detection and conversion
- Real-time collaborative drawing
- May use perfect-freehand (already in rspace deps)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Freehand drawing works with pointer/touch input
- [ ] #2 Gesture recognition detects basic shapes
- [ ] #3 Drawing state syncs across clients
- [ ] #4 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,47 @@
---
id: TASK-30
title: Port folk-holon shape (H3 geospatial hex hierarchy)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-3
- data-integration
milestone: m-0
dependencies:
- TASK-24
- TASK-25
references:
- canvas-website/src/shapes/HolonShapeUtil.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port HolonShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/HolonShapeUtil.tsx (1,216 lines)
Target: rspace-online/lib/folk-holon.ts
Features to implement:
- H3 geospatial hexagonal hierarchy visualization
- HoloSphere network connection (real-time)
- Multi-lens data viewing (switch between data perspectives)
- In-place editing with auto-resize
- Location props: latitude, longitude, resolution
- Content props: name, description, holonId
- State: isConnected, isEditing, selectedLens, data, connections
Dependencies: h3-js (TASK-24), /api/holon/* endpoints (TASK-25)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 H3 hex hierarchy renders correctly
- [ ] #2 HoloSphere network connection works
- [ ] #3 Multi-lens data switching functional
- [ ] #4 Geospatial props sync across clients
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,36 @@
---
id: TASK-31
title: Port folk-holon-browser shape (Holon network browser)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-3
- data-integration
milestone: m-0
dependencies:
- TASK-30
references:
- canvas-website/src/shapes/HolonBrowserShapeUtil.tsx
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port HolonBrowserShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/HolonBrowserShapeUtil.tsx (202 lines)
Target: rspace-online/lib/folk-holon-browser.ts
Features: Network visualization, search, filtering through Holon data. Companion to folk-holon — shares HoloSphere service client.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Browse/search Holon network data
- [ ] #2 Visualization renders network graph
- [ ] #3 Can open individual Holons from browser
- [ ] #4 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,47 @@
---
id: TASK-32
title: Port folk-obsidian-browser shape (Obsidian vault explorer)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-3
- data-integration
milestone: m-0
dependencies:
- TASK-25
references:
- canvas-website/src/shapes/ObsidianBrowserShapeUtil.tsx
- canvas-website/src/components/ObsidianVaultBrowser.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port ObsidianBrowserShapeUtil + ObsidianVaultBrowser component from canvas-website to rspace-online.
Source: canvas-website/src/shapes/ObsidianBrowserShapeUtil.tsx (413 lines) + canvas-website/src/components/ObsidianVaultBrowser.tsx (1,694 lines)
Target: rspace-online/lib/folk-obsidian-browser.ts
Total: 2,107 lines — one of the largest ports.
Features to implement:
- Obsidian vault file tree navigation
- Full-text search across vault
- Backlink preview and navigation
- Note opening (creates folk-obs-note shapes)
- Vault metadata display
Needs /api/obsidian/* server endpoints for local vault file operations (TASK-25).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 File tree renders vault directory structure
- [ ] #2 Full-text search returns matching notes
- [ ] #3 Backlink preview displays on hover/click
- [ ] #4 Selecting a note creates folk-obs-note shape
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,44 @@
---
id: TASK-33
title: Port folk-fathom-browser shape (Fathom meetings integration)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-3
- data-integration
milestone: m-0
dependencies:
- TASK-25
references:
- canvas-website/src/shapes/FathomMeetingsBrowserShapeUtil.tsx
- canvas-website/src/components/FathomMeetingsPanel.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port FathomMeetingsBrowserShapeUtil + FathomMeetingsPanel from canvas-website to rspace-online.
Source: canvas-website/src/shapes/FathomMeetingsBrowserShapeUtil.tsx (549 lines) + canvas-website/src/components/FathomMeetingsPanel.tsx (705 lines)
Target: rspace-online/lib/folk-fathom-browser.ts
Features to implement:
- Fathom API integration (meeting list, transcripts)
- Meeting list with search/filter
- Transcript search with speaker identification
- Open individual meetings as folk-fathom-note shapes
Needs /api/fathom/* proxy endpoints (TASK-25).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Fathom API returns meeting list
- [ ] #2 Search/filter meetings works
- [ ] #3 Speaker identification displayed
- [ ] #4 Selecting meeting creates folk-fathom-note
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,42 @@
---
id: TASK-34
title: Port folk-fathom-note shape (individual meeting note)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-3
- data-integration
milestone: m-0
dependencies:
- TASK-33
references:
- canvas-website/src/shapes/FathomNoteShapeUtil.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port FathomNoteShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/FathomNoteShapeUtil.tsx (667 lines)
Target: rspace-online/lib/folk-fathom-note.ts
Features to implement:
- Individual meeting note display
- Speaker clips with timestamps
- Timestamp linking (click to jump)
- Note editing and annotation
- Created by folk-fathom-browser when user selects a meeting
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Meeting note renders with transcript content
- [ ] #2 Speaker clips display correctly
- [ ] #3 Timestamp links navigate within transcript
- [ ] #4 Note editing syncs across clients
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,46 @@
---
id: TASK-35
title: Port folk-multmux shape (xterm.js terminal emulator)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-4
- dev-tools
milestone: m-0
dependencies:
- TASK-24
- TASK-25
references:
- canvas-website/src/shapes/MultmuxShapeUtil.tsx
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port MultmuxShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/MultmuxShapeUtil.tsx (850 lines)
Target: rspace-online/lib/folk-multmux.ts
Features to implement:
- xterm.js terminal emulator in a shape
- WebSocket session management with auto-reconnect
- Session naming and persistence
- Fit addon for responsive terminal sizing
- Shape migration support (versioning)
Dependencies: @xterm/xterm, @xterm/addon-fit (TASK-24)
Needs WebSocket terminal endpoint on server (TASK-25).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Terminal renders with xterm.js
- [ ] #2 WebSocket connection to terminal session works
- [ ] #3 Auto-reconnect on disconnect
- [ ] #4 Session state persists across page reloads
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,41 @@
---
id: TASK-36
title: Port folk-private-workspace shape (data sovereignty zone)
status: To Do
assignee: []
created_date: '2026-02-18 19:50'
labels:
- shape-port
- phase-4
- privacy
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/shapes/PrivateWorkspaceShapeUtil.tsx
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port PrivateWorkspaceShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/PrivateWorkspaceShapeUtil.tsx (370 lines)
Target: rspace-online/lib/folk-private-workspace.ts
Features to implement:
- Data sovereignty container zone
- Visibility badges (public/private indicators)
- Private data compartmentalization
- Works with existing folk-google-item shape
- Drag-in/drag-out items to change privacy scope
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Workspace zone renders with privacy boundary
- [ ] #2 Visibility badges display correctly
- [ ] #3 Items inside zone respect privacy scope
- [ ] #4 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,47 @@
---
id: TASK-37
title: Port folk-transaction-builder shape (Safe multisig)
status: To Do
assignee: []
created_date: '2026-02-18 19:51'
labels:
- shape-port
- phase-4
- web3
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/shapes/TransactionBuilderShapeUtil.tsx
- canvas-website/src/components/safe/TransactionComposer.tsx
- canvas-website/src/components/safe/PendingTransactions.tsx
- canvas-website/src/components/safe/TransactionHistory.tsx
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port TransactionBuilderShapeUtil + Safe components from canvas-website to rspace-online.
Source: canvas-website/src/shapes/TransactionBuilderShapeUtil.tsx (157 lines) + canvas-website/src/components/safe/ (585 lines total: SafeHeader, TransactionComposer, PendingTransactions, TransactionHistory)
Target: rspace-online/lib/folk-transaction-builder.ts
Features to implement:
- Transaction composition UI (select recipient, amount, data)
- Pending transaction queue display
- Transaction history view
- Mode switching: compose/pending/history
- Safe wallet integration
May need safe-apps-sdk or ethers.js dependency (TASK-24).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Transaction composer creates valid transactions
- [ ] #2 Pending queue displays waiting transactions
- [ ] #3 History view shows past transactions
- [ ] #4 Mode switching works (compose/pending/history)
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,44 @@
---
id: TASK-38
title: Port folk-calendar-event shape (calendar event sub-shape)
status: To Do
assignee: []
created_date: '2026-02-18 19:51'
labels:
- shape-port
- phase-4
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/shapes/CalendarEventShapeUtil.tsx
- rspace-online/lib/folk-calendar.ts
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port CalendarEventShapeUtil from canvas-website to rspace-online.
Source: canvas-website/src/shapes/CalendarEventShapeUtil.tsx (457 lines)
Target: rspace-online/lib/folk-calendar-event.ts
Features to implement:
- Individual calendar event display
- Time/date formatting and display
- Recurrence info visualization
- Color coding by event type/calendar
- Created by existing folk-calendar when user drags out an event
Companion to existing folk-calendar shape.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Event renders with title, time, and color
- [ ] #2 Recurrence info displays correctly
- [ ] #3 folk-calendar can create calendar-event shapes
- [ ] #4 Event data syncs across clients
- [ ] #5 Toolbar button added to canvas.html
<!-- AC:END -->

View File

@ -0,0 +1,50 @@
---
id: TASK-39
title: Port MycelialIntelligence system (global AI bar + shape)
status: To Do
assignee: []
created_date: '2026-02-18 19:51'
labels:
- shape-port
- phase-5
- ai
- infrastructure
milestone: m-0
dependencies:
- TASK-25
references:
- canvas-website/src/ui/MycelialIntelligenceBar.tsx
- canvas-website/src/shapes/MycelialIntelligenceShapeUtil.tsx
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port the MycelialIntelligence system from canvas-website to rspace-online. This is a GLOBAL UI element (not just a shape).
Sources:
- canvas-website/src/ui/MycelialIntelligenceBar.tsx (2,231 lines) — the main AI bar
- canvas-website/src/shapes/MycelialIntelligenceShapeUtil.tsx (69 lines) — backward-compat shape
Target: rspace-online/lib/mycelial-intelligence-bar.ts (Web Component) + rspace-online/lib/folk-mycelial-intelligence.ts (shape)
This is the largest single migration item. Implement in phases:
Phase A: Basic chat UI bar (fixed bottom bar with prompt input + response display)
Phase B: Canvas context awareness (knows selected shapes, viewport contents)
Phase C: Shape creation/modification via AI commands (create shapes, edit properties)
Phase D: Full tool integration (all AI capabilities available through bar)
The bar should be added as a persistent element in canvas.html, independent of the shape system.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 AI bar renders as persistent bottom UI element
- [ ] #2 Chat prompt sends to LLM and displays responses
- [ ] #3 Bar is context-aware of selected shapes and canvas state
- [ ] #4 Can create/modify shapes via AI commands
- [ ] #5 Backward-compat folk-mycelial-intelligence shape exists
- [ ] #6 Toolbar button toggles bar visibility
<!-- AC:END -->

View File

@ -0,0 +1,53 @@
---
id: TASK-40
title: Port workflow engine (propagators + execution)
status: To Do
assignee: []
created_date: '2026-02-18 19:51'
labels:
- infrastructure
- phase-6
- workflow
milestone: m-0
dependencies:
- TASK-24
references:
- canvas-website/src/lib/workflow/
- canvas-website/src/propagators/WorkflowPropagator.ts
- canvas-website/src/propagators/ScopedPropagators.ts
- rspace-online/lib/folk-workflow-block.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Port the workflow execution engine from canvas-website to rspace-online. This powers the existing folk-workflow-block shape with actual data flow and execution.
Source files (3,676 lines total):
- canvas-website/src/lib/workflow/types.ts (159 lines) — type definitions
- canvas-website/src/lib/workflow/blockRegistry.ts (438 lines) — block definition registry
- canvas-website/src/lib/workflow/executor.ts (548 lines) — block execution engine
- canvas-website/src/lib/workflow/portBindings.ts (464 lines) — port connection logic
- canvas-website/src/lib/workflow/validation.ts (417 lines) — graph validation
- canvas-website/src/lib/workflow/serialization.ts (571 lines) — save/load workflows
- canvas-website/src/propagators/WorkflowPropagator.ts (326 lines) — real-time data flow
Target: rspace-online/lib/workflow/ directory
Key adaptation needed: canvas-website uses tldraw store for state; rspace-online uses Automerge. The execution engine needs to read/write shape data through CommunitySync instead of tldraw's store.
Also port relevant propagator concepts:
- canvas-website/src/propagators/ScopedPropagators.ts (314 lines)
- canvas-website/src/propagators/SpatialIndex.ts (164 lines)
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Block registry loads available block types
- [ ] #2 Port connections transfer data between blocks
- [ ] #3 Execution engine runs blocks in correct order
- [ ] #4 Validation catches invalid graph configurations
- [ ] #5 Workflows serialize/deserialize through Automerge
- [ ] #6 Real-time propagation updates connected blocks
<!-- AC:END -->

View File

@ -0,0 +1,41 @@
---
id: TASK-41
title: Build dynamic Shape Registry to replace hardcoded switch statements
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- infrastructure
- phase-0
- ecosystem
milestone: m-1
dependencies: []
references:
- rspace-online/lib/folk-shape.ts
- rspace-online/website/canvas.html
- rspace-online/lib/community-sync.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the 170-line switch statement in canvas.html's `createShapeElement()` and the 100-line type-switch in community-sync.ts's `#updateShapeElement()` with a dynamic ShapeRegistry.
Create lib/shape-registry.ts with:
- ShapeRegistration interface (tagName, elementClass, defaults, category, portDescriptors, eventDescriptors)
- ShapeRegistry class with register(), createElement(), updateElement(), listAll(), getByCategory()
- Each folk-*.ts gets a static `registration` property and static `fromData()` method
This is the prerequisite for all other ecosystem features (pipes, events, groups, nesting, embedding).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 ShapeRegistry class created with register/createElement/updateElement methods
- [ ] #2 All 30+ folk-*.ts shapes have static registration property
- [ ] #3 canvas.html switch statement replaced with registry.createElement()
- [ ] #4 community-sync.ts type-switch replaced with registry.updateElement()
- [ ] #5 All existing shapes still create and sync correctly
- [ ] #6 No regression in shape creation or remote sync
<!-- AC:END -->

View File

@ -0,0 +1,62 @@
---
id: TASK-42
title: 'Implement Data Pipes: typed data flow through arrows'
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- feature
- phase-1
- ecosystem
milestone: m-1
dependencies:
- TASK-41
references:
- rspace-online/lib/folk-arrow.ts
- rspace-online/lib/folk-shape.ts
- rspace-online/lib/folk-image-gen.ts
- rspace-online/lib/folk-prompt.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Transform folk-arrow from visual-only connector into a typed data conduit between shapes.
New file lib/data-types.ts:
- DataType enum: string, number, boolean, image-url, video-url, text, json, trigger, any
- Type compatibility matrix and isCompatible() function
Add port mixin to FolkShape:
- ports map, getPort(), setPortValue(), onPortValueChanged()
- Port values stored in Automerge: doc.shapes[id].ports[name].value
- 100ms debounce on port propagation to prevent keystroke thrashing
Enhance folk-arrow:
- sourcePort/targetPort fields referencing named ports
- Listen for port-value-changed on source, push to target
- Type compatibility check before pushing
- Visual: arrows tinted by data type, flow animation when active
- Port handle UI during connect mode
Add port descriptors to AI shapes:
- folk-image-gen: input "prompt" (text), output "image" (image-url)
- folk-video-gen: input "prompt" (text), input "image" (image-url), output "video" (video-url)
- folk-prompt: input "context" (text), output "response" (text)
- folk-transcription: output "transcript" (text)
Example pipeline: Transcription →[text]→ Prompt →[text]→ ImageGen →[image-url]→ VideoGen
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 DataType system with compatibility matrix works
- [ ] #2 Shapes can declare input/output ports via registration
- [ ] #3 setPortValue() writes to Automerge and dispatches event
- [ ] #4 folk-arrow pipes data from source port to target port
- [ ] #5 Type incompatible connections show warning
- [ ] #6 Arrows visually indicate data type and active flow
- [ ] #7 Port values sync to remote clients via Automerge
- [ ] #8 100ms debounce prevents thrashing on rapid changes
<!-- AC:END -->

View File

@ -0,0 +1,48 @@
---
id: TASK-43
title: 'Implement Event Broadcasting: canvas-wide pub/sub system'
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- feature
- phase-2
- ecosystem
milestone: m-1
dependencies:
- TASK-41
references:
- rspace-online/lib/community-sync.ts
- rspace-online/server/community-store.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a pub/sub event system so shapes can broadcast and subscribe to named events across the canvas.
New file lib/event-bus.ts:
- CanvasEventBus class with emit(), subscribe(), unsubscribe(), getSubscribers()
- Events written to CRDT doc.eventLog (bounded ring buffer, last 100 entries)
- Remote users see events replayed via Automerge patch application
- Re-entrancy guard kills chains after 10 levels to prevent infinite loops
Automerge schema additions:
- doc.eventLog: EventEntry[] (id, channel, sourceShapeId, payload, timestamp)
- shapes[id].subscriptions: string[] (channel names)
Shapes opt in with onEventReceived(channel, payload) method.
Example: Timer emits "timer:done" → all subscribed Budget shapes recalculate.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 CanvasEventBus emits events to CRDT eventLog
- [ ] #2 Shapes can subscribe to channels and receive events
- [ ] #3 Events sync to remote users via Automerge
- [ ] #4 Ring buffer bounded at 100 entries with GC
- [ ] #5 Re-entrancy guard prevents infinite event loops
- [ ] #6 Works offline (events queued in CRDT, replayed on reconnect)
<!-- AC:END -->

View File

@ -0,0 +1,58 @@
---
id: TASK-44
title: 'Implement Semantic Grouping: named shape clusters with templates'
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- feature
- phase-3
- ecosystem
milestone: m-1
dependencies:
- TASK-41
references:
- rspace-online/lib/DOMRectTransform.ts
- rspace-online/lib/community-sync.ts
- rspace-online/server/community-store.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add the ability to group shapes into named clusters that can be collapsed, moved together, and saved as reusable templates.
New file lib/group-manager.ts:
- GroupManager class: createGroup(), dissolveGroup(), addToGroup(), removeFromGroup()
- collapseGroup() / expandGroup() — hide members, show summary card
- moveGroup(groupId, dx, dy) — moves all members by delta
- saveAsTemplate() / instantiateTemplate() — serialize group as reusable JSON template
Automerge schema additions:
- doc.groups: { [groupId]: GroupData } — top-level CRDT entity
- GroupData: id, name, color, icon, memberShapeIds[], collapsed, x, y, width, height
- shapes[id].groupId: string — which group a shape belongs to
Visual rendering:
- folk-group-frame — lightweight overlay element (NOT a FolkShape), dashed border + header bar
- Recalculates bounds from member shapes on each animation frame
- Uses existing DOMRectTransform.ts for rotated shape bounding boxes
- Collapse button, group name, color indicator
Canvas.html additions:
- Rubber-band or shift-click multi-select → right-click "Group"
- Group context menu (rename, change color, collapse, save as template, dissolve)
- Template library panel for saved templates
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 GroupManager creates/dissolves groups stored in Automerge
- [ ] #2 Moving group header moves all member shapes together
- [ ] #3 Collapse hides members and shows summary card
- [ ] #4 Expand restores all members to original positions
- [ ] #5 Groups sync to remote users via Automerge
- [ ] #6 Save as template serializes group + internal arrows as JSON
- [ ] #7 Instantiate template creates new shapes from template
<!-- AC:END -->

View File

@ -0,0 +1,57 @@
---
id: TASK-45
title: 'Implement Shape Nesting: shapes containing shapes + recursive canvas'
status: To Do
assignee: []
created_date: '2026-02-18 20:06'
labels:
- feature
- phase-4
- ecosystem
milestone: m-1
dependencies:
- TASK-44
references:
- rspace-online/lib/folk-shape.ts
- rspace-online/lib/community-sync.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Allow shapes to contain other shapes, including a recursive canvas shape.
Automerge schema additions (flat references, NOT nested objects):
- shapes[id].parentId: string — nested inside this shape
- shapes[id].childIds: string[] — contains these shapes
New shape lib/folk-canvas.ts:
- A shape that IS a canvas — renders child shapes inside scrollable/zoomable container
- Optional linkedCommunitySlug to show shapes from another community (cross-canvas embedding)
- Own zoom/pan controls within the mini-canvas
Coordinate system:
- Children store ABSOLUTE canvas coordinates in CRDT (simplifies sync, prevents jitter)
- folk-canvas applies CSS transform offset so children appear inside it
- When parent moves, nesting manager applies delta to all children
- Nesting is a render-time concern, not a data-model concern
FolkShape additions:
- parentShape getter, childShapes getter
- addChild(), removeChild()
- toParentCoords(), toCanvasCoords() for coordinate transforms
Canvas.html: drag-drop shape onto folk-canvas to nest it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Shapes can be nested inside folk-canvas via drag-drop
- [ ] #2 Nested shapes move with parent when parent is moved
- [ ] #3 folk-canvas has its own zoom/pan controls
- [ ] #4 parentId/childIds sync correctly via Automerge
- [ ] #5 Un-nesting a shape restores it to top-level canvas
- [ ] #6 No coordinate jitter when two users move parent and child simultaneously
- [ ] #7 Optional cross-canvas linking via linkedCommunitySlug
<!-- AC:END -->

View File

@ -0,0 +1,75 @@
---
id: TASK-46
title: 'Implement Cross-App Embedding: r-ecosystem apps in rSpace canvases'
status: In Progress
assignee: []
created_date: '2026-02-18 20:07'
updated_date: '2026-02-26 03:50'
labels:
- feature
- phase-5
- ecosystem
milestone: m-1
dependencies:
- TASK-41
- TASK-42
references:
- rspace-online/lib/shape-registry.ts
- rspace-online/server/index.ts
- rspace-online/website/canvas.html
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Allow r-ecosystem apps (rWallet, rVote, rMaps, etc.) to embed their live UI into rSpace canvases via dynamically loaded Web Components.
Ecosystem App Manifest Protocol:
- Each app hosts /.well-known/rspace-manifest.json
- Manifest declares: appId, name, icon, moduleUrl, shapes[] (tagName, defaults, portDescriptors, eventDescriptors)
New file lib/ecosystem-bridge.ts:
- EcosystemBridge class: loadManifest(), loadModule(), createSandboxedEmbed()
- Two embedding modes:
1. Trusted (Web Component): dynamic import(), shares CRDT directly, full port/event access
2. Sandboxed (iframe): postMessage bridge for untrusted apps, limited API
New Automerge shape type:
- type: "ecosystem-embed", appId, moduleUrl, config, sandboxed boolean
Server additions:
- GET /api/ecosystem/:appId/manifest — proxy to avoid CORS
- Server pre-fetches and caches ecosystem manifests
Canvas.html additions:
- Dynamic toolbar section for ecosystem apps (loaded from manifests)
- Lazy module loading on first use
- Service Worker caches modules for offline
Runtime:
1. Server fetches ecosystem manifests → toolbar shows app buttons
2. User adds ecosystem shape → module import()-ed → Web Component registered → shape created
3. Remote sync: create-shape for ecosystem-embed triggers lazy module load on other clients
4. Embedded components participate in port/event system like native shapes
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Ecosystem manifest protocol defined and documented
- [ ] #2 EcosystemBridge loads manifests and dynamic imports modules
- [ ] #3 Trusted Web Components share CRDT and port/event system
- [ ] #4 Sandboxed iframe mode works with postMessage bridge
- [ ] #5 Server proxy avoids CORS for manifest/module loading
- [x] #6 Toolbar dynamically shows ecosystem app buttons
- [x] #7 Remote clients lazy-load modules when ecosystem shapes appear
- [ ] #8 Service Worker caches ecosystem modules for offline
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
POC implemented in commit 50f0e11: folk-rapp shape type embeds live rApp modules as iframes on the canvas. Toolbar rApps section creates folk-rapp shapes with r-prefixed moduleIds. Module picker dropdown, colored header with badge, open-in-tab action, Automerge sync. Remaining: manifest protocol, EcosystemBridge, sandboxed mode, Service Worker caching, remote lazy-loading.
Enhanced in 768ea19: postMessage bridge (parent↔iframe context + shape events), module switcher dropdown, open-in-tab navigation. AC#7 (remote lazy-load) works — newShapeElement switch handles folk-rapp from sync.
<!-- SECTION:NOTES:END -->

View File

@ -0,0 +1,32 @@
---
id: TASK-48
title: Fix TypeScript build errors blocking deployment
status: Done
assignee: []
created_date: '2026-02-24 03:51'
labels:
- bugfix
- typescript
- build
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Resolved all TypeScript compilation errors that were preventing `npm run build` from succeeding on the server. The build command `tsc --noEmit && vite build` was failing with ~50 errors across multiple modules.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 tsc --noEmit passes with zero errors
- [ ] #2 vite build completes successfully
- [ ] #3 Docker build and deploy succeeds on Netcup
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed ~50 TypeScript errors across the codebase:\n\n- **9 modules** (cal, cart, forum, inbox, notes, providers, trips, vote, work): Changed `unknown[]``any[]` for `sql.unsafe()` parameter arrays. The postgres.js `ParameterOrJSON<never>` type was too restrictive when no custom types are defined.\n- **website/sw.ts**: Excluded from tsconfig — service worker needs WebWorker types, not DOM types, and is built separately by Vite.\n- **src/lib/demo-sync.ts**: Excluded from tsconfig — React hook for external consumers, React not a project dependency.\n- **modules/splat**: Fixed Hono context `c.get()` typing via cast, `instanceof File` via `unknown` cast, and inline sql.unsafe arrays.\n- **modules/swag/folk-swag-designer.ts**: Renamed `private title``private designTitle` to avoid collision with `HTMLElement.title`.\n- **types/gaussian-splats-3d.d.ts**: Added type declaration stub for CDN-loaded package.\n\nCommit: b2f1beb on main. Deployed to Netcup successfully.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,32 @@
---
id: TASK-49
title: Add admin dashboard at /admin with space overview
status: Done
assignee:
- '@claude'
created_date: '2026-02-24 23:29'
labels:
- feature
- admin
- frontend
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Added an admin dashboard page at /admin that shows all rSpace spaces with detailed stats including shape count, member count, file size on disk, visibility, creation date, and owner DID. Includes search, filter by visibility, and sort controls. Also added /api/spaces/admin API endpoint returning all space data.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Admin page accessible at /admin
- [ ] #2 Shows all spaces with shape count, member count, file size
- [ ] #3 Search bar filters by name/slug/owner
- [ ] #4 Visibility filter buttons work
- [ ] #5 Sort dropdown works (date, name, shapes, size)
- [ ] #6 API endpoint at /api/spaces/admin returns detailed space data
- [ ] #7 Vite build includes admin.html
- [ ] #8 Consistent styling with existing rSpace dark theme
<!-- AC:END -->

View File

@ -0,0 +1,87 @@
---
id: TASK-50
title: Implement nested spaces architecture with permission cascade
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 02:27'
updated_date: '2026-02-25 02:43'
labels:
- architecture
- spaces
- permissions
- encryptid
dependencies: []
references:
- server/community-store.ts
- server/spaces.ts
- src/encryptid/server.ts
- lib/community-sync.ts
documentation:
- docs/SPACE-ARCHITECTURE.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Spaces are now nestable — any space can embed references to other spaces via SpaceRef, with a permission cascade model (most-restrictive-wins at each nesting boundary). Every EncryptID registration auto-provisions a sovereign space at &lt;username&gt;.rspace.online with consent-based nesting controls.
Core principle: a space is a space is a space. No type field distinguishing personal vs community. The "personal" quality emerges from ownership + permissions, not a schema distinction.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 NestPolicy type with consent levels (open/members/approval/closed)
- [ ] #2 SpaceRef CRUD endpoints on /api/spaces/:slug/nest
- [ ] #3 Permission cascade via intersection (most-restrictive-wins)
- [ ] #4 Approval flow for nest requests with admin review
- [ ] #5 Source space admins can always revoke nestings (sovereignty guarantee)
- [ ] #6 Auto-provision <username>.rspace.online on EncryptID registration
- [ ] #7 defaultPermissions ceiling caps requested permissions
- [ ] #8 Allowlist/blocklist per space
- [ ] #9 Reverse lookup (nested-in) endpoint
- [ ] #10 Client-side types for nested space rendering
- [ ] #11 TypeScript compiles clean
- [ ] #12 Full architecture spec at docs/SPACE-ARCHITECTURE.md
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Phase 3-5 implemented and pushed to dev:
- Phase 3: folk-canvas nested space shape with live WS, auto-scaling, collapsed/expanded views
- Phase 4: WS cascade enforcement — nest filter on broadcasts, addShapes/deleteShapes checks
- Phase 5: AES-256-GCM at-rest encryption with transparent encrypt/decrypt and API endpoints
- All phases type-check clean (npx tsc --noEmit)
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Phase 1+2 Implementation Complete
### Schema changes (community-store.ts)
- New types: NestPermissions, NestNotifications, NestPolicy, SpaceRef, SpaceRefFilter, PendingNestRequest
- Default policies: DEFAULT_USER_NEST_POLICY (approval consent) and DEFAULT_COMMUNITY_NEST_POLICY (members consent)
- Updated CommunityMeta with enabledModules, description, avatar, nestPolicy, encrypted fields
- Updated CommunityDoc with nestedSpaces map
- CRUD: addNestedSpace, updateNestedSpace, removeNestedSpace, getNestPolicy, updateNestPolicy, setEnabledModules
- Permission logic: capPermissions (ceiling), cascadePermissions (intersection)
- Reverse lookup: findNestedIn
### REST API (spaces.ts)
- GET/PATCH /:slug/nest-policy
- GET/POST /:slug/nest (with full consent flow)
- GET/PATCH/DELETE /:slug/nest/:refId
- GET /:slug/nested-in
- GET/PATCH /:slug/nest-requests (approval flow)
### Auto-provisioning (encryptid/server.ts)
- After registration, creates &lt;username&gt;.rspace.online with members_only visibility, user nest policy, default modules
### Remaining phases
- Phase 3: folk-canvas shape renderer for SpaceRef entries
- Phase 4: Full cascade enforcement on WebSocket writes
- Phase 5: Encryption integration
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,44 @@
---
id: TASK-51
title: Consolidate standalone r*.online domains → rspace.online
status: To Do
assignee: []
created_date: '2026-02-25 07:46'
labels:
- infrastructure
- domains
- migration
dependencies: []
references:
- server/index.ts (lines 457-521 — standalone rewrite logic)
- shared/module.ts (standaloneDomain interface)
- shared/components/rstack-app-switcher.ts (external link arrows)
- docker-compose.yml (lines 44-114 — Traefik labels)
- src/encryptid/server.ts (allowedOrigins list)
- src/encryptid/session.ts (JWT aud claim)
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Migrate ~20 standalone domains (rbooks.online, rmaps.online, rfunds.online, etc.) into path-based routing under rspace.online. Currently these domains are silently rewritten to /:space/:moduleId internally — this migration makes rspace.online the canonical URL, adds 301 redirects, removes dead infrastructure, and lets domains expire.
Execution order: Phase 2 → 1 → 3 → 4 → 5 → 6 (fix external service URLs before enabling redirects).
Key risks:
- rnetwork.online is dual-purpose (module alias AND TWENTY_API_URL) — must decouple before redirects
- sync.rmaps.online is a separate WebSocket service, not the rmaps module
- PWA/service worker caches on old domains may need self-unregistering workers
- keepStandalone domains (rcart.online, rfiles.online, swag.mycofi.earth, providers.mycofi.earth) need separate evaluation
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All standalone domain hits return 301 → rspace.online/demo/{moduleId}/...
- [ ] #2 No hardcoded references to r*.online domains remain in codebase
- [ ] #3 WebAuthn/EncryptID auth works solely on rspace.online
- [ ] #4 Standalone Traefik labels and docker-compose.standalone.yml removed
- [ ] #5 Standalone .ts entry points deleted
- [ ] #6 Domain registrations allowed to expire
<!-- AC:END -->

View File

@ -0,0 +1,31 @@
---
id: TASK-51.1
title: 'Phase 2: Fix external service URLs (analytics, maps sync, Twenty CRM)'
status: To Do
assignee: []
created_date: '2026-02-25 07:47'
labels:
- infrastructure
- domains
- migration
dependencies: []
parent_task_id: TASK-51
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update hardcoded references to standalone domains used as service endpoints. Must be done BEFORE Phase 1 redirects to avoid breaking analytics, maps sync, and network module.
Files: website/index.html, website/create-space.html (collect.js), docker-compose.yml (MAPS_SYNC_URL, TWENTY_API_URL), modules/maps/mod.ts, modules/network/mod.ts.
DECISION NEEDED: Is Twenty CRM (rnetwork.online) a separate container or proxied through network module?
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Analytics collect.js loads from relative path on rspace.online
- [ ] #2 Maps sync WebSocket connects via new URL
- [ ] #3 Network module reaches Twenty CRM without depending on rnetwork.online domain
<!-- AC:END -->

View File

@ -0,0 +1,31 @@
---
id: TASK-51.2
title: 'Phase 1: Convert standalone domain rewrite to 301 redirects'
status: To Do
assignee: []
created_date: '2026-02-25 07:47'
labels:
- infrastructure
- domains
- migration
dependencies:
- TASK-51.1
parent_task_id: TASK-51
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Change server/index.ts standalone domain handling from silent rewrite to 301 redirect for HTML page loads. Keep API/WS rewriting so running apps don't break. Traefik labels stay — domains must still route to the container to serve the 301.
Target: server/index.ts lines 482-521. Redirect HTML page loads, continue proxying /api/* and /ws/* requests. keepStandalone domains unaffected.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 rmaps.online/some-room returns 301 to rspace.online/demo/maps/some-room
- [ ] #2 rbooks.online/ returns 301 to rspace.online/demo/books
- [ ] #3 API and WebSocket requests still proxied without redirect
- [ ] #4 keepStandalone domains unaffected
<!-- AC:END -->

View File

@ -0,0 +1,31 @@
---
id: TASK-51.3
title: 'Phase 3: Update UI links (app switcher, landing page)'
status: To Do
assignee: []
created_date: '2026-02-25 07:47'
labels:
- infrastructure
- domains
- migration
- ui
dependencies:
- TASK-51.2
parent_task_id: TASK-51
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Remove standalone domain references from user-facing UI. Remove external link arrow from app switcher, update landing page ecosystem links to path-based routes, remove standaloneDomain from ModuleInfo interface.
Files: shared/components/rstack-app-switcher.ts, shared/module.ts, website/index.html, website/create-space.html.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 App switcher shows no external link arrows
- [ ] #2 Landing page ecosystem links use /demo/{moduleId} paths
- [ ] #3 ModuleInfo no longer exposes standaloneDomain to client
<!-- AC:END -->

View File

@ -0,0 +1,32 @@
---
id: TASK-51.4
title: 'Phase 4: Simplify EncryptID and WebAuthn for single domain'
status: To Do
assignee: []
created_date: '2026-02-25 07:47'
labels:
- infrastructure
- domains
- migration
- auth
dependencies:
- TASK-51.3
parent_task_id: TASK-51
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prune WebAuthn Related Origins, JWT audience claims, and CORS allowedOrigins now that all modules are on rspace.online.
Files: server/index.ts (.well-known/webauthn), public/.well-known/webauthn, src/encryptid/session.ts (JWT aud), src/encryptid/server.ts (allowedOrigins + HTML templates).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Passkey login works on rspace.online
- [ ] #2 No CORS errors for auth flows
- [ ] #3 JWT aud is rspace.online only
- [ ] #4 .well-known/webauthn no longer lists standalone domains
<!-- AC:END -->

View File

@ -0,0 +1,32 @@
---
id: TASK-51.5
title: 'Phase 5: Remove standalone domain dead code and infrastructure'
status: To Do
assignee: []
created_date: '2026-02-25 07:48'
updated_date: '2026-02-25 07:48'
labels:
- infrastructure
- domains
- migration
- cleanup
dependencies:
- TASK-51.4
parent_task_id: TASK-51
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
After 301 redirects have been live 3-6 months, strip all standalone domain machinery. Delete domainToModule map, keepStandalone set, rewrite/redirect block in server/index.ts. Remove standaloneDomain from RSpaceModule interface and all 22 mod.ts files. Delete all 20 standalone.ts entry points. Remove Traefik labels. Delete docker-compose.standalone.yml.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 No references to standaloneDomain remain in codebase
- [ ] #2 No standalone.ts files exist
- [ ] #3 docker-compose.standalone.yml deleted
- [ ] #4 Traefik config only has rspace.online and *.rspace.online routers
- [ ] #5 All modules work via path-based routing
<!-- AC:END -->

View File

@ -0,0 +1,31 @@
---
id: TASK-51.6
title: 'Phase 6: DNS cleanup and domain expiry'
status: To Do
assignee: []
created_date: '2026-02-25 07:48'
updated_date: '2026-02-25 07:48'
labels:
- infrastructure
- domains
- migration
- dns
dependencies:
- TASK-51.5
parent_task_id: TASK-51
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Operational phase — no code changes. Monitor Cloudflare analytics on old domains for 3-6 months. Remove tunnel hostname entries, DNS zones. Let Porkbun registrations expire. Keep rspace.online, ridentity.online, rstack.online, rmail.online. Evaluate keepStandalone domains separately.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 No Cloudflare tunnel entries for expired domains
- [ ] #2 No DNS zones for expired domains
- [ ] #3 Domain renewals cancelled at Porkbun
- [ ] #4 Core domains retained
<!-- AC:END -->

View File

@ -0,0 +1,32 @@
---
id: TASK-52
title: Redesign canvas toolbar with grouped dropdowns and collapse
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 21:07'
labels:
- ui
- canvas
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
The canvas toolbar had 28+ flat tool buttons in a single horizontal row that ran off screen on most displays. Redesigned with grouped dropdown menus and a collapse/minimize toggle.
**Changes:**
- 6 category dropdowns: Create, Media, Embed, AI, Travel, Decide
- Direct-access buttons for Connect, Memory, and Zoom controls
- Collapse toggle (◀/▶) to minimize toolbar to a single button
- Mobile responsive: accordion-style groups instead of floating dropdowns
- Click-outside-to-close and auto-close-on-tool-select behavior
<!-- SECTION:DESCRIPTION:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Replaced 28 flat toolbar buttons with 6 grouped dropdowns (Create, Media, Embed, AI, Travel, Decide) plus direct-access Connect/Memory/Zoom buttons. Added collapsible toolbar toggle. Mobile-responsive with accordion-style groups. Commit: 5c3db2c on dev branch.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,38 @@
---
id: TASK-53
title: Redesign shared identity modal and user dropdown
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 22:59'
labels:
- identity
- ux
- shared-header
dependencies: []
references:
- shared/components/rstack-identity.ts
- server/shell.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Redesign the rstack-identity web component shared across all rApps. Replace the old auth modal with a unified "Sign up / Sign in" landing page, and replace Profile/Recovery dropdown items (which routed to auth.ridentity.online) with in-app account settings modals.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Auth modal shows unified Sign up / Sign in with stacked passkey buttons
- [ ] #2 Close X button and Powered by EncryptID link to ridentity.online on all modal views
- [ ] #3 Register view has Back button instead of inline toggle
- [ ] #4 Logged-in dropdown shows username header with Add Email, Add Second Device, Add Social Recovery
- [ ] #5 No more navigation to auth.ridentity.online from the header
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented in commit 0647f19. Auth modal redesigned with unified landing, close buttons, EncryptID branding link. User dropdown restructured with account settings items replacing old Profile/Recovery links. All changes in rstack-identity.ts web component, shared across all 22 rApps via the shell renderer.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,35 @@
---
id: TASK-54
title: Update space switcher with emoji visibility badges and button styling
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 22:59'
labels:
- ux
- shared-header
dependencies: []
references:
- shared/components/rstack-space-switcher.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update rstack-space-switcher to use emoji visibility indicators (green unlocked, yellow key, red lock) instead of text labels, and match the trigger button styling to the rApp switcher.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Public spaces show green unlocked emoji
- [ ] #2 Permissioned spaces show yellow key emoji
- [ ] #3 Private spaces show red lock emoji
- [ ] #4 Trigger button matches app-switcher style (font-size, colors, no slash prefix)
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented in commit 0647f19. Visibility badges changed from text (PUBLIC/PRIVATE/PERMISSIONED) to emojis (🔓/🔑/🔒) with color-coded backgrounds. Trigger button updated: removed slash prefix, bumped font to 0.9rem, matched app-switcher color scheme.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,45 @@
---
id: TASK-55
title: >-
Wire up account settings endpoints (email verification, device registration,
guardians)
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 22:59'
labels:
- identity
- backend
- encryptid
dependencies: []
references:
- src/encryptid/server.ts
- src/encryptid/db.ts
- shared/components/rstack-identity.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add server-side endpoints for the three account settings features and wire up the client modals to use them. Email verification uses SMTP with 6-digit codes. Device registration uses WebAuthn for same-device passkey addition. Social recovery uses the existing guardian API.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 POST /api/account/email/start sends 6-digit code via SMTP
- [ ] #2 POST /api/account/email/verify validates code and sets email on account
- [ ] #3 POST /api/account/device/start returns WebAuthn creation options for authenticated user
- [ ] #4 POST /api/account/device/complete stores new credential under existing account
- [ ] #5 Social recovery modal loads guardians from GET /api/guardians on open
- [ ] #6 Adding guardian calls POST /api/guardians with name + optional email
- [ ] #7 Removing guardian calls DELETE /api/guardians/:id
- [ ] #8 StoredChallenge.type includes device_registration
- [ ] #9 StoredRecoveryToken.type includes email_verification
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented in commit 914d0e6. Added 4 new server endpoints under /api/account/ namespace. Email verification sends styled HTML email with 6-digit code via Mailcow SMTP, stores as recovery token. Device registration reuses existing challenge/credential infrastructure with new device_registration type. Client social recovery modal rewritten to use existing guardian API (add/remove individual guardians, load on open, show status). DB types extended for new token/challenge types.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,42 @@
---
id: TASK-56
title: Consistent headers across all rApps + mi AI assistant
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 23:10'
labels:
- ux
- shared-header
- ai
- mi
dependencies: []
references:
- shared/components/rstack-mi.ts
- server/index.ts
- server/shell.ts
- website/public/shell.css
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Make all rApp headers identical (fix height mismatches, remove custom overrides) and add the mi AI assistant search bar to every page header.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 All 7 module CSS files use 56px header height (not 52px)
- [ ] #2 No module CSS overrides the shared header background
- [ ] #3 All headers have 3-section layout: left (switchers) / center (mi) / right (identity)
- [ ] #4 rstack-mi component registered in shell.ts and present in all 4 HTML pages + both shell renderers
- [ ] #5 POST /api/mi/ask streams from Ollama with rApp-aware system prompt
- [ ] #6 Fallback responses when Ollama unavailable
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented in commit 0813eed. Fixed 52px→56px in 7 module CSS files (pubs, funds, providers, books, swag, choices, cart). Removed header background overrides from books.css and pubs.css. Created rstack-mi web component with streaming chat UI and added to all pages. Server endpoint /api/mi/ask proxies to Ollama with dynamic system prompt built from all 22 registered modules. Keyword-based fallback when AI service is offline. Configurable via MI_MODEL and OLLAMA_URL env vars.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,97 @@
---
id: TASK-57
title: Layered tab system with inter-layer flows and bidirectional feeds
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 23:31'
updated_date: '2026-02-25 23:31'
labels:
- feature
- canvas
- architecture
milestone: rspace-app-ecosystem
dependencies: []
references:
- lib/layer-types.ts
- lib/folk-feed.ts
- shared/components/rstack-tab-bar.ts
- lib/community-sync.ts
- shared/module.ts
- server/shell.ts
- website/canvas.html
- website/shell.ts
- website/public/shell.css
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Implement a layered tab system where each rApp becomes a "layer" that can be viewed as tabs (flat mode) or stacked strata (stack view). Layers connect via typed flows (economic, trust, data, attention, governance, resource) enabling inter-app data sharing. Feed shapes on the canvas pull live data from other layers' APIs with bidirectional write-back support.
## 4-Phase Implementation
**Phase 1 — Tab Bar UI + Layer Configuration**
- Created `rstack-tab-bar` web component with flat (tabs) and stack (SVG side-view) modes
- Drag-to-reorder tabs, add/close layers
- Extended Automerge CommunityDoc with layers, flows, activeLayerId, layerViewMode
- Core types: Layer, LayerFlow, FlowKind in `lib/layer-types.ts`
**Phase 2 — Feed Definitions on Modules**
- Added FeedDefinition interface to shared/module.ts
- Added feeds and acceptsFeeds to 10 modules: funds, notes, vote, choices, wallet, data, work, network, trips, canvas
- Each module declares what feed kinds it exposes and accepts
**Phase 3 — Folk Feed Shape**
- Built `folk-feed` canvas shape that fetches live data from other layers' module APIs
- Module-specific endpoint mapping and response normalization
- Auto-refresh on configurable interval
- Auto-flow detection when creating feed shapes
**Phase 4 — Bidirectional Flows**
- Edit overlay with module-specific fields for write-back via PUT/PATCH
- Click-through navigation (double-click items)
- Drag-to-connect flows in stack view with kind/label/strength dialog
- Right-click to delete flows
- Full event wiring in shell.ts for all layer/flow CRUD operations
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tab bar renders above canvas with flat/stack view toggle
- [x] #2 Layers persist in Automerge CommunityDoc for real-time sync
- [x] #3 10 modules declare feed definitions with FlowKind types
- [x] #4 folk-feed shape fetches live data from source module APIs
- [x] #5 Bidirectional write-back saves edits to source module
- [x] #6 Drag-to-connect in stack view creates typed flows
- [x] #7 Flow creation dialog with kind, label, and strength
- [x] #8 Auto-flow detection when creating feed shapes
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Summary
Implemented a 4-phase layered tab system enabling inter-app data flows across the rSpace canvas.
## Files Changed (18 files, +2539 lines)
**New files:**
- `lib/layer-types.ts` (100 lines) — Core types: FlowKind, Layer, LayerFlow, FLOW_COLORS/FLOW_LABELS
- `lib/folk-feed.ts` (887 lines) — Canvas shape fetching live data from other layers with bidirectional write-back
- `shared/components/rstack-tab-bar.ts` (1080 lines) — Tab bar web component with flat/stack views, drag-to-connect flows
**Modified files:**
- `lib/community-sync.ts` (+149 lines) — Extended CommunityDoc with layers/flows, 11 new CRDT methods
- `shared/module.ts` (+31 lines) — FeedDefinition interface, feeds/acceptsFeeds on RSpaceModule
- `server/shell.ts` (+50 lines) — Tab bar HTML, event wiring, CommunitySync integration
- `website/canvas.html` (+87 lines) — folk-feed registration, toolbar button, auto-flow detection
- `website/shell.ts` (+2 lines) — Component registration
- `website/public/shell.css` (+25 lines) — Tab row positioning
- `lib/index.ts` (+3 lines) — folk-feed barrel export
- 10 module mod.ts files — Feed definitions for funds, notes, vote, choices, wallet, data, work, network, trips, canvas
## Commit
`cd440f1` feat: layered tab system with inter-layer flows and bidirectional feeds — merged to main
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,42 @@
---
id: TASK-58
title: >-
Auto-route users to personal/demo space + landing overlay + demo content for
all rApps
status: Done
assignee:
- '@claude'
created_date: '2026-02-25 23:53'
labels:
- feature
- routing
- ux
- demo
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Anon users automatically land on the demo space when viewing any rApp (standalone or unified). Logged-in users auto-redirect to their personal space (auto-provisioned on first visit). Landing page renders as a quarter-screen popup overlay on the demo space. Demo space seeded with content for all 22 rApps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Anon users on standalone domains (rpubs.online/) see demo space
- [ ] #2 Logged-in users auto-redirect from demo to personal space
- [ ] #3 POST /api/spaces/auto-provision creates personal space on first auth visit
- [ ] #4 Standalone domains support /<space> path prefix (rpubs.online/jeff)
- [ ] #5 rspace.online/ redirects to /demo/canvas
- [ ] #6 Quarter-screen welcome overlay shows on first demo visit
- [ ] #7 Full landing page accessible at /about
- [ ] #8 Sign-in/register triggers auto-space-resolution redirect
- [ ] #9 Demo space seeded with shapes for all 22 rApps (55 shapes total)
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented auto-routing: anon→demo, auth→personal space. Added POST /api/spaces/auto-provision endpoint, modified standalone domain rewrite to support /<space> paths, added welcome overlay to shell, wired auth flow to auto-redirect after sign-in. Seeded demo with 18 new shapes covering files, forum, books, pubs, swag, providers, work, cal, network, tube, inbox, data, choices, splat. Deployed to production, demo reset to 55 shapes.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,39 @@
---
id: TASK-59
title: Add rPhotos module + finish header standardization across all rApps
status: Done
assignee:
- '@claude'
created_date: '2026-02-26 00:06'
labels:
- photos
- headers
- modules
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Created modules/photos/ with Immich API proxy, gallery component (shared albums, lightbox viewer), and standard rapp-nav header. Registered in server/index.ts with vite build step. Fixed remaining module headers in books (shelf + reader), splat, swag, tube. All 23 modules now use consistent rapp-nav pattern. Immich confirmed running at demo.rphotos.online, landing page live at rphotos.online.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 modules/photos/ created with mod.ts, component, and CSS
- [ ] #2 Immich API proxy routes for albums, assets, thumbnails
- [ ] #3 folk-photo-gallery component with rapp-nav header
- [ ] #4 Module registered in server/index.ts
- [ ] #5 Vite build step added in vite.config.ts
- [ ] #6 books shelf-header and reader-header replaced with rapp-nav
- [ ] #7 splat h1 branding removed
- [ ] #8 swag h2 branding removed
- [ ] #9 tube tabs converted to rapp-nav
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Commit eba0aaf merged to main. All 23 modules standardized with rapp-nav headers. rPhotos module added as #23 with Immich gateway. Both rphotos.online (landing) and demo.rphotos.online (Immich) confirmed live and healthy.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,59 @@
---
id: TASK-60
title: Canonical subdomain routing + rSwag landing page simplification
status: Done
assignee:
- '@claude'
created_date: '2026-02-26 00:44'
labels:
- routing
- infrastructure
- simplification
dependencies: []
references:
- server/index.ts
- server/shell.ts
- shared/url-helpers.ts
- shared/components/rstack-app-switcher.ts
- shared/components/rstack-space-switcher.ts
- shared/components/rstack-identity.ts
- website/shell.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Consolidated URL routing so all rApps flow through `{space}.rspace.online/{moduleId}` as the canonical URL pattern, replacing the previous silent URL rewriting from standalone domains.
**rspace-online changes:**
- Subdomain handler now routes ALL modules (previously only served canvas)
- Standalone domains (rvote.online, rphotos.online, etc.) now 301 redirect to canonical `{space}.rspace.online/{moduleId}`
- Created `shared/url-helpers.ts` with `rspaceNavUrl()`, `getCurrentSpace()`, `getCurrentModule()`, `isSubdomain()`
- Updated app-switcher, space-switcher, identity component, and tab-bar navigation to use subdomain-aware URL generation
- Shell inline scripts use global `__rspaceNavUrl()` for all URL generation
- Path-based `rspace.online/:space/:moduleId` still works as fallback
- WebSocket connections on standalone domains still rewritten (WS can't follow redirects)
**rswag-online changes:**
- Replaced full Next.js + FastAPI + PostgreSQL + Redis docker-compose with simple static nginx landing page
- Updated landing page CTA: "Try the Demo" → `https://rspace.online/demo/swag`
- Aligns with the "simple components, JS and HTML wherever possible" philosophy
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 demo.rspace.online/vote serves vote module in demo space
- [ ] #2 Standalone domains (rvote.online etc) 301 redirect to canonical subdomain URL
- [ ] #3 App-switcher and space-switcher generate subdomain-aware links
- [ ] #4 Auto-provision redirect uses subdomain URL pattern
- [ ] #5 Path-based rspace.online/:space/:moduleId still works
- [ ] #6 rswag.online serves static landing page instead of Next.js app
- [ ] #7 TypeScript compiles cleanly
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented canonical subdomain routing `{space}.rspace.online/{moduleId}` across rspace-online and simplified rswag-online from Next.js to static landing page.\n\nCommits:\n- rspace-online `eab24e2`: feat: canonical subdomain routing\n- rswag-online `1eca70d`: feat: replace Next.js app with static landing page, add demo CTA\n\nBoth repos pushed to main on Gitea.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,45 @@
---
id: TASK-61
title: Canvas tab bar + rApps toolbar + iframe shell for all modules
status: Done
assignee:
- '@claude'
created_date: '2026-02-26 02:04'
updated_date: '2026-02-26 02:04'
labels:
- canvas
- rApps
- toolbar
- tab-bar
- iframe-shell
dependencies: []
references:
- website/canvas.html
- server/shell.ts
- shared/components/rstack-tab-bar.ts
- shared/url-helpers.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add the missing tab bar to the canvas page so users can switch between rApp layers (with full CommunitySync/Automerge persistence). Add an "rApps" toolbar group that embeds any of the 18 remaining modules as interactive iframes directly on the canvas. Switch all 20 module page routes to renderIframeShell, loading standalone domains inside the unified shell.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Canvas page renders rstack-tab-bar between header and toolbar
- [x] #2 Tab bar initializes with canvas as default layer and persists via CommunitySync
- [x] #3 New rApps toolbar group with 18 module embed buttons
- [x] #4 Each rApp button creates a folk-embed shape with correct module URL
- [x] #5 All 20 module page routes use renderIframeShell with standalone domains
- [x] #6 renderIframeShell function added to server/shell.ts
- [x] #7 Build passes with no errors
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Changes (22 files, +343 / -142)\n\n### Canvas tab bar (website/canvas.html)\n- Added `<rstack-tab-bar>` element between header and toolbar\n- Tab bar init JS: default canvas layer, layer-switch/add/close navigation\n- CommunitySync wiring: persists layers, flows, reorder, view mode to Automerge CRDT\n- Syncs remote layer/flow changes from other users\n\n### rApps toolbar group (website/canvas.html)\n- New \"rApps\" dropdown with 18 module embed buttons\n- Each creates a folk-embed shape pointing to the module URL via rspaceNavUrl()\n- Modules: rNotes, rPhotos, rBooks, rPubs, rFiles, rWork, rForum, rInbox, rTube, rFunds, rWallet, rVote, rCart, rData, rNetwork, rSplat, rProviders, rSwag\n\n### Iframe shell (server/shell.ts + 20 modules)\n- New renderIframeShell() wraps standalone app domains in the unified rSpace shell\n- All 20 module page routes switched from renderShell to renderIframeShell\n- Modules load their standalone domain (e.g. rnotes.online) in a seamless iframe\n\nCommit: 5c2c7f4 on dev, merged to main
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,39 @@
---
id: TASK-62
title: Tab bar persistence + iframe loading/error states
status: Done
assignee: []
created_date: '2026-02-26 02:19'
updated_date: '2026-02-26 02:19'
labels:
- bugfix
- shell
- iframe
dependencies:
- TASK-61
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix two issues from the iframe shell rollout: (1) tab bar was resetting to a single tab on every page navigation — now persists in localStorage, (2) iframed standalone apps that aren't running showed a blank page — now shows spinner + 12s timeout error panel with retry/open-directly actions. Also converts rSwag to iframe shell (missed in TASK-61).
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tabs persist across full-page navigations via localStorage
- [x] #2 Opening a new rApp adds it as a tab alongside existing ones
- [x] #3 Closing active tab navigates to first remaining tab
- [x] #4 Iframe shows loading spinner while standalone app loads
- [x] #5 12s timeout shows error panel if standalone app unreachable
- [x] #6 Error panel has Retry button and Open Directly link
- [x] #7 rSwag module converted to iframe shell
- [x] #8 CommunitySync merges with localStorage tabs when connected
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Rewrote tab bar initialization in `server/shell.ts` to use `localStorage` keyed per space (`rspace_tabs_{spaceSlug}`). Previously `tabBar.setLayers([defaultLayer])` was called with only the current module on every page load, wiping all previous tabs. Now reads from localStorage first, adds the current module if missing, and persists before rendering.\n\nAdded loading/error states to `renderIframeShell()`: spinner overlay during load, 12s timeout that shows an error panel with the module name, domain that failed, Retry button, and Open Directly link. Iframe fades in on successful load.\n\nConverted `modules/swag/mod.ts` to use `renderIframeShell` with `swag.mycofi.earth` (missed in TASK-61).\n\nCommit: `fa6a2ce` — merged to main.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,52 @@
---
id: TASK-63
title: Remove iframe shell — render all modules directly via web components
status: Done
assignee: []
created_date: '2026-02-26 02:46'
updated_date: '2026-02-26 02:46'
labels:
- refactor
- bug-fix
- shell
dependencies: []
references:
- server/shell.ts
- server/index.ts
- modules/*/mod.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Every module except canvas was using renderIframeShell() to embed standalone domains (rdata.online, rwork.online, etc.) via iframe. None of these domains had independent deployments — they routed back to the same container, causing infinite redirect loops (for modules not in keepStandalone) or 404s (for modules in keepStandalone).
Root cause: renderIframeShell created an iframe pointing to e.g. rdata.online, which Traefik routed back to the same rspace-online container. For non-keepStandalone modules, the server 301-redirected back to demo.rspace.online/work, which rendered another iframe shell → infinite loop. For keepStandalone modules, the request fell through to Hono but no root route matched → connection refused.
Fix: All 22 modules now render their web components directly inside renderShell(), eliminating cross-origin failures, iframe loading spinners, and ~820 lines of dead code. Standalone domain requests are internally rewritten to module routes instead of 301 redirecting.
Changes:
- Removed renderIframeShell(), renderStandaloneShell(), IframeShellOptions from server/shell.ts
- Removed keepStandalone set; standalone domains now internally rewrite to /{space}/{moduleId}
- Converted all 22 module GET / handlers from renderIframeShell → renderShell + direct <folk-*> web components
- Deleted 20 standalone.ts entry points (were circular/broken)
- Net: +129 / -947 lines across 43 files
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All 22 modules render via renderShell() with direct web component tags
- [x] #2 renderIframeShell and IframeShellOptions removed from server/shell.ts
- [x] #3 Standalone domains (rdata.online etc.) internally rewrite to module routes instead of 301 redirect
- [x] #4 keepStandalone set removed
- [x] #5 All 20 standalone.ts files deleted
- [x] #6 TypeScript compiles cleanly (bunx tsc --noEmit passes)
- [x] #7 No remaining references to renderIframeShell in source code
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed the broken iframe shell architecture. All modules now render their `<folk-*>` web components directly in the unified shell, fixing connection failures across rData, rWork, rNetwork, rWallet, rFiles, and all other modules. Standalone domains are internally rewritten instead of 301 redirected. Commit c2729fb, merged to main.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,43 @@
---
id: TASK-64
title: r-prefix module slugs + landing page + clickable rStack header
status: Done
assignee: []
created_date: '2026-02-26 03:04'
updated_date: '2026-02-26 03:05'
labels:
- refactor
- routing
- shell
dependencies: []
references:
- server/index.ts
- shared/components/rstack-app-switcher.ts
- shared/components/rstack-tab-bar.ts
- shared/url-helpers.ts
- modules/*/mod.ts
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Rename all 23 module IDs from bare names to r-prefixed slugs (canvas→rspace, notes→rnotes, vote→rvote, etc.) so URLs are consistent with rApp branding. Root rspace.online/ now serves the landing page instead of redirecting to demo. rStack header in app switcher is now a clickable link. rSpace itself appears as a module peer alongside all other rApps.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All 23 module IDs use r-prefix slugs (rspace, rnotes, rvote, etc.)
- [x] #2 Root rspace.online/ serves landing page (not redirect to demo)
- [x] #3 rStack header in app switcher dropdown is clickable (links to rstack.online)
- [x] #4 Space root redirects to /rspace instead of /canvas
- [x] #5 All internal navigation links updated to r-prefixed paths
- [x] #6 Badge maps in app switcher and tab bar use r-prefixed keys
- [x] #7 TypeScript compiles cleanly
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Renamed all 23 module IDs to r-prefixed slugs across 33 files. Root domain now serves the landing page. rStack header is clickable. All badge maps, URL helpers, internal links, and redirects updated. Commit 4895af1, merged to main.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@ -0,0 +1,34 @@
---
id: TASK-HIGH.1
title: 'Bare-domain module routing — rspace.online/{moduleId} as default'
status: Done
assignee: []
created_date: '2026-02-26 03:34'
updated_date: '2026-02-26 06:20'
labels: []
dependencies: []
parent_task_id: TASK-HIGH
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
App dropdown links go to rspace.online/r* (bare domain) instead of demo.rspace.online/r*. Only 'Try Demo' button links to demo subdomain. Server internally rewrites bare-domain module paths to /demo/{moduleId}.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 App dropdown generates bare-domain URLs (/{moduleId}) on rspace.online
- [x] #2 Server rewrites rspace.online/{moduleId} → /demo/{moduleId} internally
- [x] #3 url-helpers isBareDomain() + getCurrentSpace/Module handle bare domain
- [x] #4 Try Demo button visible on bare domain, hidden on demo.rspace.online
- [x] #5 Auto-provision redirects authenticated users to personal subdomain
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented in commit a732478. Changed url-helpers.ts (isBareDomain, rspaceNavUrl), server/index.ts (fetch handler rewrite), server/shell.ts (data-hide + client JS for Try Demo). Merged to main.
Follow-up fix (c15fc15): app switcher fallback was 'personal' → 'demo', landing page demo links & ecosystem app links updated to use rspace.online/r* bare domain pattern
<!-- SECTION:NOTES:END -->

23
db/init.sql Normal file
View File

@ -0,0 +1,23 @@
-- rSpace shared PostgreSQL — per-module schema isolation
-- Each module owns its schema. Modules that don't need a DB skip this.
-- Extensions available to all schemas
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "cube";
CREATE EXTENSION IF NOT EXISTS "earthdistance";
-- Module schemas (created on init, populated by module migrations)
CREATE SCHEMA IF NOT EXISTS rbooks;
CREATE SCHEMA IF NOT EXISTS rcart;
CREATE SCHEMA IF NOT EXISTS providers;
CREATE SCHEMA IF NOT EXISTS rfiles;
CREATE SCHEMA IF NOT EXISTS rforum;
CREATE SCHEMA IF NOT EXISTS rsplat;
-- Grant usage to the rspace user
GRANT ALL ON SCHEMA rbooks TO rspace;
GRANT ALL ON SCHEMA rcart TO rspace;
GRANT ALL ON SCHEMA providers TO rspace;
GRANT ALL ON SCHEMA rfiles TO rspace;
GRANT ALL ON SCHEMA rforum TO rspace;
GRANT ALL ON SCHEMA rsplat TO rspace;

View File

@ -25,7 +25,7 @@ services:
labels:
# Traefik auto-discovery
- "traefik.enable=true"
- "traefik.http.routers.encryptid.rule=Host(`auth.rspace.online`) || Host(`encryptid.jeffemmett.com`)"
- "traefik.http.routers.encryptid.rule=Host(`auth.rspace.online`) || Host(`auth.ridentity.online`) || Host(`encryptid.jeffemmett.com`)"
- "traefik.http.routers.encryptid.entrypoints=web"
- "traefik.http.routers.encryptid.priority=150"
- "traefik.http.services.encryptid.loadbalancer.server.port=3000"
@ -34,6 +34,11 @@ services:
- "traefik.http.routers.encryptid-wellknown.entrypoints=web"
- "traefik.http.routers.encryptid-wellknown.priority=200"
- "traefik.http.routers.encryptid-wellknown.service=encryptid"
# Serve .well-known/webauthn from ridentity.online too
- "traefik.http.routers.encryptid-wellknown-rid.rule=Host(`ridentity.online`) && PathPrefix(`/.well-known/webauthn`)"
- "traefik.http.routers.encryptid-wellknown-rid.entrypoints=web"
- "traefik.http.routers.encryptid-wellknown-rid.priority=200"
- "traefik.http.routers.encryptid-wellknown-rid.service=encryptid"
networks:
- traefik-public
- encryptid-internal

View File

@ -0,0 +1,316 @@
# Standalone module deployments — each module runs as its own container.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d rbooks-standalone
#
# All services reuse the same rspace-online image (built by the main compose).
# They share the rspace-db database and traefik-public network.
# To deploy ALL standalone modules at once:
# docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d
#
# NOTE: Standalone services override the default CMD to run standalone.ts.
# The unified rspace-online container still serves all domains by default.
# To switch a domain from unified to standalone, remove its Traefik label
# from the main compose and bring up its standalone service here.
x-standalone-base: &standalone-base
image: rspace-online-rspace:latest
restart: unless-stopped
environment: &base-env
NODE_ENV: production
PORT: "3000"
DATABASE_URL: postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
depends_on:
rspace-db:
condition: service_healthy
networks:
- traefik-public
- rspace-internal
x-traefik-labels: &traefik-enabled
traefik.enable: "true"
services:
# ── rBooks ──
rbooks-standalone:
<<: *standalone-base
container_name: rbooks-standalone
command: ["bun", "run", "modules/books/standalone.ts"]
volumes:
- rspace-books:/data/books
environment:
<<: *base-env
BOOKS_DIR: /data/books
labels:
<<: *traefik-enabled
traefik.http.routers.rbooks-sa.rule: Host(`rbooks.online`)
traefik.http.routers.rbooks-sa.entrypoints: web
traefik.http.services.rbooks-sa.loadbalancer.server.port: "3000"
# ── rPubs ──
rpubs-standalone:
<<: *standalone-base
container_name: rpubs-standalone
command: ["bun", "run", "modules/pubs/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rpubs-sa.rule: Host(`rpubs.online`)
traefik.http.routers.rpubs-sa.entrypoints: web
traefik.http.services.rpubs-sa.loadbalancer.server.port: "3000"
# ── rCart ──
rcart-standalone:
<<: *standalone-base
container_name: rcart-standalone
command: ["bun", "run", "modules/cart/standalone.ts"]
environment:
<<: *base-env
FLOW_SERVICE_URL: http://payment-flow:3010
FLOW_ID: a79144ec-e6a2-4e30-a42a-6d8237a5953d
FUNNEL_ID: 0ff6a9ac-1667-4fc7-9a01-b1620810509f
X402_PAY_TO: ${X402_PAY_TO:-}
X402_NETWORK: ${X402_NETWORK:-eip155:84532}
X402_UPLOAD_PRICE: ${X402_UPLOAD_PRICE:-0.01}
X402_FACILITATOR_URL: ${X402_FACILITATOR_URL:-https://x402.org/facilitator}
networks:
- traefik-public
- rspace-internal
- payment-network
labels:
<<: *traefik-enabled
traefik.http.routers.rcart-sa.rule: Host(`rcart.online`)
traefik.http.routers.rcart-sa.entrypoints: web
traefik.http.services.rcart-sa.loadbalancer.server.port: "3000"
# ── rSwag ──
rswag-standalone:
<<: *standalone-base
container_name: rswag-standalone
command: ["bun", "run", "modules/swag/standalone.ts"]
volumes:
- rspace-swag:/data/swag-artifacts
environment:
<<: *base-env
SWAG_ARTIFACTS_DIR: /data/swag-artifacts
labels:
<<: *traefik-enabled
traefik.http.routers.rswag-sa.rule: Host(`rswag.online`)
traefik.http.routers.rswag-sa.entrypoints: web
traefik.http.services.rswag-sa.loadbalancer.server.port: "3000"
# ── rChoices ──
rchoices-standalone:
<<: *standalone-base
container_name: rchoices-standalone
command: ["bun", "run", "modules/choices/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rchoices-sa.rule: Host(`rchoices.online`)
traefik.http.routers.rchoices-sa.entrypoints: web
traefik.http.services.rchoices-sa.loadbalancer.server.port: "3000"
# ── rFunds ──
rfunds-standalone:
<<: *standalone-base
container_name: rfunds-standalone
command: ["bun", "run", "modules/funds/standalone.ts"]
environment:
<<: *base-env
FLOW_SERVICE_URL: http://payment-flow:3010
FLOW_ID: a79144ec-e6a2-4e30-a42a-6d8237a5953d
FUNNEL_ID: 0ff6a9ac-1667-4fc7-9a01-b1620810509f
networks:
- traefik-public
- rspace-internal
- payment-network
labels:
<<: *traefik-enabled
traefik.http.routers.rfunds-sa.rule: Host(`rfunds.online`)
traefik.http.routers.rfunds-sa.entrypoints: web
traefik.http.services.rfunds-sa.loadbalancer.server.port: "3000"
# ── rFiles ──
rfiles-standalone:
<<: *standalone-base
container_name: rfiles-standalone
command: ["bun", "run", "modules/files/standalone.ts"]
volumes:
- rspace-files:/data/files
environment:
<<: *base-env
FILES_DIR: /data/files
labels:
<<: *traefik-enabled
traefik.http.routers.rfiles-sa.rule: Host(`rfiles.online`)
traefik.http.routers.rfiles-sa.entrypoints: web
traefik.http.services.rfiles-sa.loadbalancer.server.port: "3000"
# ── rForum ──
rforum-standalone:
<<: *standalone-base
container_name: rforum-standalone
command: ["bun", "run", "modules/forum/standalone.ts"]
environment:
<<: *base-env
HETZNER_API_TOKEN: ${HETZNER_API_TOKEN}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CLOUDFLARE_ZONE_ID: ${CLOUDFLARE_ZONE_ID}
labels:
<<: *traefik-enabled
traefik.http.routers.rforum-sa.rule: Host(`rforum.online`)
traefik.http.routers.rforum-sa.entrypoints: web
traefik.http.services.rforum-sa.loadbalancer.server.port: "3000"
# ── rVote ──
rvote-standalone:
<<: *standalone-base
container_name: rvote-standalone
command: ["bun", "run", "modules/vote/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rvote-sa.rule: Host(`rvote.online`)
traefik.http.routers.rvote-sa.entrypoints: web
traefik.http.services.rvote-sa.loadbalancer.server.port: "3000"
# ── rNotes ──
rnotes-standalone:
<<: *standalone-base
container_name: rnotes-standalone
command: ["bun", "run", "modules/notes/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rnotes-sa.rule: Host(`rnotes.online`)
traefik.http.routers.rnotes-sa.entrypoints: web
traefik.http.services.rnotes-sa.loadbalancer.server.port: "3000"
# ── rMaps ──
rmaps-standalone:
<<: *standalone-base
container_name: rmaps-standalone
command: ["bun", "run", "modules/maps/standalone.ts"]
environment:
<<: *base-env
MAPS_SYNC_URL: wss://sync.rmaps.online
labels:
<<: *traefik-enabled
traefik.http.routers.rmaps-sa.rule: Host(`rmaps.online`)
traefik.http.routers.rmaps-sa.entrypoints: web
traefik.http.services.rmaps-sa.loadbalancer.server.port: "3000"
# ── rWork ──
rwork-standalone:
<<: *standalone-base
container_name: rwork-standalone
command: ["bun", "run", "modules/work/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rwork-sa.rule: Host(`rwork.online`)
traefik.http.routers.rwork-sa.entrypoints: web
traefik.http.services.rwork-sa.loadbalancer.server.port: "3000"
# ── rTrips ──
rtrips-standalone:
<<: *standalone-base
container_name: rtrips-standalone
command: ["bun", "run", "modules/trips/standalone.ts"]
environment:
<<: *base-env
OSRM_URL: http://osrm-backend:5000
labels:
<<: *traefik-enabled
traefik.http.routers.rtrips-sa.rule: Host(`rtrips.online`)
traefik.http.routers.rtrips-sa.entrypoints: web
traefik.http.services.rtrips-sa.loadbalancer.server.port: "3000"
# ── rCal ──
rcal-standalone:
<<: *standalone-base
container_name: rcal-standalone
command: ["bun", "run", "modules/cal/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rcal-sa.rule: Host(`rcal.online`)
traefik.http.routers.rcal-sa.entrypoints: web
traefik.http.services.rcal-sa.loadbalancer.server.port: "3000"
# ── rNetwork ──
rnetwork-standalone:
<<: *standalone-base
container_name: rnetwork-standalone
command: ["bun", "run", "modules/network/standalone.ts"]
environment:
<<: *base-env
TWENTY_API_URL: https://rnetwork.online
TWENTY_API_TOKEN: ${TWENTY_API_TOKEN}
labels:
<<: *traefik-enabled
traefik.http.routers.rnetwork-sa.rule: Host(`rnetwork.online`)
traefik.http.routers.rnetwork-sa.entrypoints: web
traefik.http.services.rnetwork-sa.loadbalancer.server.port: "3000"
# ── rTube ──
rtube-standalone:
<<: *standalone-base
container_name: rtube-standalone
command: ["bun", "run", "modules/tube/standalone.ts"]
environment:
<<: *base-env
R2_ENDPOINT: ${R2_ENDPOINT}
R2_BUCKET: ${R2_BUCKET:-rtube-videos}
R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID}
R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY}
R2_PUBLIC_URL: ${R2_PUBLIC_URL}
labels:
<<: *traefik-enabled
traefik.http.routers.rtube-sa.rule: Host(`rtube.online`)
traefik.http.routers.rtube-sa.entrypoints: web
traefik.http.services.rtube-sa.loadbalancer.server.port: "3000"
# ── rInbox ──
rinbox-standalone:
<<: *standalone-base
container_name: rinbox-standalone
command: ["bun", "run", "modules/inbox/standalone.ts"]
environment:
<<: *base-env
IMAP_HOST: mail.rmail.online
IMAP_PORT: "993"
IMAP_TLS_REJECT_UNAUTHORIZED: "false"
networks:
- traefik-public
- rspace-internal
- rmail-mailcow
labels:
<<: *traefik-enabled
traefik.http.routers.rinbox-sa.rule: Host(`rinbox.online`)
traefik.http.routers.rinbox-sa.entrypoints: web
traefik.http.services.rinbox-sa.loadbalancer.server.port: "3000"
# ── rData ──
rdata-standalone:
<<: *standalone-base
container_name: rdata-standalone
command: ["bun", "run", "modules/data/standalone.ts"]
environment:
<<: *base-env
UMAMI_URL: https://analytics.rspace.online
UMAMI_WEBSITE_ID: 292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
labels:
<<: *traefik-enabled
traefik.http.routers.rdata-sa.rule: Host(`rdata.online`)
traefik.http.routers.rdata-sa.entrypoints: web
traefik.http.services.rdata-sa.loadbalancer.server.port: "3000"
# ── rWallet ──
rwallet-standalone:
<<: *standalone-base
container_name: rwallet-standalone
command: ["bun", "run", "modules/wallet/standalone.ts"]
labels:
<<: *traefik-enabled
traefik.http.routers.rwallet-sa.rule: Host(`rwallet.online`)
traefik.http.routers.rwallet-sa.entrypoints: web
traefik.http.services.rwallet-sa.loadbalancer.server.port: "3000"
# Volumes and networks inherited from main docker-compose.yml
# Use: docker compose -f docker-compose.yml -f docker-compose.standalone.yml up -d <service>

View File

@ -8,26 +8,178 @@ services:
restart: unless-stopped
volumes:
- rspace-data:/data/communities
- rspace-books:/data/books
- rspace-swag:/data/swag-artifacts
- rspace-files:/data/files
- rspace-splats:/data/splats
- rspace-docs:/data/docs
environment:
- NODE_ENV=production
- STORAGE_DIR=/data/communities
- BOOKS_DIR=/data/books
- SWAG_ARTIFACTS_DIR=/data/swag-artifacts
- PORT=3000
- INTERNAL_API_KEY=${INTERNAL_API_KEY}
- FILES_DIR=/data/files
- SPLATS_DIR=/data/splats
- DOCS_STORAGE_DIR=/data/docs
- INFISICAL_CLIENT_ID=${INFISICAL_CLIENT_ID}
- INFISICAL_CLIENT_SECRET=${INFISICAL_CLIENT_SECRET}
- INFISICAL_PROJECT_SLUG=rspace
- INFISICAL_ENV=prod
- INFISICAL_URL=http://infisical:8080
- DATABASE_URL=postgres://rspace:${POSTGRES_PASSWORD:-rspace}@rspace-db:5432/rspace
- FLOW_SERVICE_URL=http://payment-flow:3010
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
- UMAMI_URL=https://analytics.rspace.online
- UMAMI_WEBSITE_ID=292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
- MAPS_SYNC_URL=wss://sync.rmaps.online
- IMAP_HOST=mail.rmail.online
- IMAP_PORT=993
- IMAP_TLS_REJECT_UNAUTHORIZED=false
- TWENTY_API_URL=https://rnetwork.online
depends_on:
rspace-db:
condition: service_healthy
labels:
- "traefik.enable=true"
# Only handle subdomains (rspace-prod handles main domain)
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`)"
# Main domain — serves landing + path-based routing
- "traefik.http.routers.rspace-main.rule=Host(`rspace.online`)"
- "traefik.http.routers.rspace-main.entrypoints=web"
- "traefik.http.routers.rspace-main.priority=110"
# Subdomains — backward compat for *.rspace.online canvas
- "traefik.http.routers.rspace-canvas.rule=HostRegexp(`{subdomain:[a-z0-9-]+}.rspace.online`) && !Host(`rspace.online`) && !Host(`www.rspace.online`) && !Host(`auth.rspace.online`)"
- "traefik.http.routers.rspace-canvas.entrypoints=web"
- "traefik.http.routers.rspace-canvas.priority=100"
# ── Standalone domain routing (priority 120) ──
- "traefik.http.routers.rspace-rbooks.rule=Host(`rbooks.online`)"
- "traefik.http.routers.rspace-rbooks.entrypoints=web"
- "traefik.http.routers.rspace-rbooks.priority=120"
- "traefik.http.routers.rspace-rbooks.service=rspace-online"
- "traefik.http.routers.rspace-rpubs.rule=Host(`rpubs.online`)"
- "traefik.http.routers.rspace-rpubs.entrypoints=web"
- "traefik.http.routers.rspace-rpubs.priority=120"
- "traefik.http.routers.rspace-rpubs.service=rspace-online"
- "traefik.http.routers.rspace-rchoices.rule=Host(`rchoices.online`)"
- "traefik.http.routers.rspace-rchoices.entrypoints=web"
- "traefik.http.routers.rspace-rchoices.priority=120"
- "traefik.http.routers.rspace-rchoices.service=rspace-online"
- "traefik.http.routers.rspace-rfunds.rule=Host(`rfunds.online`)"
- "traefik.http.routers.rspace-rfunds.entrypoints=web"
- "traefik.http.routers.rspace-rfunds.priority=120"
- "traefik.http.routers.rspace-rfunds.service=rspace-online"
- "traefik.http.routers.rspace-rforum.rule=Host(`rforum.online`)"
- "traefik.http.routers.rspace-rforum.entrypoints=web"
- "traefik.http.routers.rspace-rforum.priority=120"
- "traefik.http.routers.rspace-rforum.service=rspace-online"
- "traefik.http.routers.rspace-rvote.rule=Host(`rvote.online`)"
- "traefik.http.routers.rspace-rvote.entrypoints=web"
- "traefik.http.routers.rspace-rvote.priority=120"
- "traefik.http.routers.rspace-rvote.service=rspace-online"
- "traefik.http.routers.rspace-rwork.rule=Host(`rwork.online`)"
- "traefik.http.routers.rspace-rwork.entrypoints=web"
- "traefik.http.routers.rspace-rwork.priority=120"
- "traefik.http.routers.rspace-rwork.service=rspace-online"
- "traefik.http.routers.rspace-rcal.rule=Host(`rcal.online`)"
- "traefik.http.routers.rspace-rcal.entrypoints=web"
- "traefik.http.routers.rspace-rcal.priority=120"
- "traefik.http.routers.rspace-rcal.service=rspace-online"
- "traefik.http.routers.rspace-rtrips.rule=Host(`rtrips.online`)"
- "traefik.http.routers.rspace-rtrips.entrypoints=web"
- "traefik.http.routers.rspace-rtrips.priority=120"
- "traefik.http.routers.rspace-rtrips.service=rspace-online"
- "traefik.http.routers.rspace-rwallet.rule=Host(`rwallet.online`)"
- "traefik.http.routers.rspace-rwallet.entrypoints=web"
- "traefik.http.routers.rspace-rwallet.priority=120"
- "traefik.http.routers.rspace-rwallet.service=rspace-online"
- "traefik.http.routers.rspace-rdata.rule=Host(`rdata.online`)"
- "traefik.http.routers.rspace-rdata.entrypoints=web"
- "traefik.http.routers.rspace-rdata.priority=120"
- "traefik.http.routers.rspace-rdata.service=rspace-online"
- "traefik.http.routers.rspace-rnetwork.rule=Host(`rnetwork.online`)"
- "traefik.http.routers.rspace-rnetwork.entrypoints=web"
- "traefik.http.routers.rspace-rnetwork.priority=120"
- "traefik.http.routers.rspace-rnetwork.service=rspace-online"
- "traefik.http.routers.rspace-rtube.rule=Host(`rtube.online`)"
- "traefik.http.routers.rspace-rtube.entrypoints=web"
- "traefik.http.routers.rspace-rtube.priority=120"
- "traefik.http.routers.rspace-rtube.service=rspace-online"
- "traefik.http.routers.rspace-rmaps.rule=Host(`rmaps.online`)"
- "traefik.http.routers.rspace-rmaps.entrypoints=web"
- "traefik.http.routers.rspace-rmaps.priority=120"
- "traefik.http.routers.rspace-rmaps.service=rspace-online"
- "traefik.http.routers.rspace-rnotes.rule=Host(`rnotes.online`)"
- "traefik.http.routers.rspace-rnotes.entrypoints=web"
- "traefik.http.routers.rspace-rnotes.priority=120"
- "traefik.http.routers.rspace-rnotes.service=rspace-online"
- "traefik.http.routers.rspace-rfiles.rule=Host(`rfiles.online`)"
- "traefik.http.routers.rspace-rfiles.entrypoints=web"
- "traefik.http.routers.rspace-rfiles.priority=120"
- "traefik.http.routers.rspace-rfiles.service=rspace-online"
- "traefik.http.routers.rspace-rphotos.rule=Host(`rphotos.online`)"
- "traefik.http.routers.rspace-rphotos.entrypoints=web"
- "traefik.http.routers.rspace-rphotos.priority=120"
- "traefik.http.routers.rspace-rphotos.service=rspace-online"
- "traefik.http.routers.rspace-rinbox.rule=Host(`rinbox.online`)"
- "traefik.http.routers.rspace-rinbox.entrypoints=web"
- "traefik.http.routers.rspace-rinbox.priority=120"
- "traefik.http.routers.rspace-rinbox.service=rspace-online"
- "traefik.http.routers.rspace-rcart.rule=Host(`rcart.online`)"
- "traefik.http.routers.rspace-rcart.entrypoints=web"
- "traefik.http.routers.rspace-rcart.priority=120"
- "traefik.http.routers.rspace-rcart.service=rspace-online"
- "traefik.http.routers.rspace-rsplat.rule=Host(`rsplat.online`)"
- "traefik.http.routers.rspace-rsplat.entrypoints=web"
- "traefik.http.routers.rspace-rsplat.priority=120"
- "traefik.http.routers.rspace-rsplat.service=rspace-online"
- "traefik.http.routers.rspace-rswag.rule=Host(`rswag.online`)"
- "traefik.http.routers.rspace-rswag.entrypoints=web"
- "traefik.http.routers.rspace-rswag.priority=120"
- "traefik.http.routers.rspace-rswag.service=rspace-online"
# Service configuration
- "traefik.http.services.rspace-canvas.loadbalancer.server.port=3000"
- "traefik.http.services.rspace-online.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
networks:
- traefik-public
- rspace-internal
- payment-network
- rmail-mailcow
rspace-db:
image: postgres:16-alpine
container_name: rspace-db
restart: unless-stopped
volumes:
- rspace-pgdata:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
environment:
- POSTGRES_DB=rspace
- POSTGRES_USER=rspace
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-rspace}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U rspace"]
interval: 5s
timeout: 3s
retries: 5
networks:
- rspace-internal
volumes:
rspace-data:
rspace-books:
rspace-swag:
rspace-files:
rspace-splats:
rspace-docs:
rspace-pgdata:
networks:
traefik-public:
external: true
payment-network:
name: payment-infra_payment-network
external: true
rmail-mailcow:
name: mailcowdockerized_mailcow-network
external: true
rspace-internal:

824
docs/SPACE-ARCHITECTURE.md Normal file
View File

@ -0,0 +1,824 @@
# Space Architecture: Nested Spaces & Permission Cascade
*Version 0.1 — February 2026*
---
## Core Principle: A Space Is a Space Is a Space
There is no `type` field. There is no distinction between "personal" and "community" at the schema level. A space is the singular primitive. What varies between spaces is:
- **Who owns it** — the `ownerDID` and admin set
- **Who can see into it** — visibility + membership
- **What it nests** — references to other spaces
- **What apps are deployed on it** — enabled modules
A space where one person is the sole admin with `members_only` visibility *behaves* like a personal space. A space with many members and `public_read` visibility *behaves* like a community. But they are the same data structure, the same Automerge document, the same API surface. This uniformity is what makes arbitrary nesting depth work without special cases.
---
## `<username>.rspace.online` — Identity-Provisioned Spaces
Every EncryptID registration auto-provisions a space:
```
Registration → EncryptID created → Space provisioned at <username>.rspace.online
```
This space is not special. It's a space where:
- The user is the sole `admin`
- Visibility defaults to `members_only` (sovereign by default)
- The user's chosen apps are enabled
- It exists at a predictable, memorable URL
### What this gives the user
1. **A sovereign home on the network**`alice.rspace.online` is Alice's address. Her canvas, her apps, her data.
2. **An app deployment surface** — Alice enables rWallet, rVote, rNotes on her space. They're live immediately.
3. **The root of her sharing tree** — When Alice shares content into a DAO, it originates from her space. When she revokes, it disappears from the DAO. Her space is always the canonical source.
4. **A URL she can give someone** — "Find me at `alice.rspace.online`." The equivalent of a homepage, but living.
### What this does NOT mean
- It is NOT a different kind of space
- It does NOT have special server-side logic beyond auto-provisioning
- It CAN be reconfigured — Alice could make it `public`, add members, change its name
- Other spaces CAN also be provisioned at `<anything>.rspace.online` — the subdomain is just a slug
---
## Nested Spaces
Nesting is the mechanism for both sharing and composition. A space can reference other spaces, which appear as embedded regions on its canvas.
### SpaceRef: The Nesting Primitive
```typescript
interface SpaceRef {
id: string; // unique ref ID within this space
sourceSlug: string; // the nested space's slug
sourceDID?: string; // who created this nesting (provenance)
// What to show from the nested space
filter?: SpaceRefFilter;
// Where it appears on the parent canvas
x: number;
y: number;
width: number;
height: number;
rotation: number;
// What the parent space's members can do with nested content
permissions: NestPermissions;
// Display
collapsed?: boolean; // render as a card summary vs. full canvas
label?: string; // override display name
}
interface SpaceRefFilter {
// Show all shapes, or a subset?
shapeTypes?: string[]; // only these shape types (e.g., ['folk-note', 'folk-budget'])
shapeIds?: string[]; // only these specific shapes
tags?: string[]; // only shapes with these tags (future)
moduleIds?: string[]; // only content from these modules
}
interface NestPermissions {
read: boolean; // can members of the parent space see this nested content?
write: boolean; // can they edit shapes in the nested space?
addShapes: boolean; // can they add new shapes to the nested space?
deleteShapes: boolean; // can they delete shapes from the nested space?
reshare: boolean; // can they nest this space further into other spaces?
expiry?: number; // unix timestamp — auto-revoke after this time
}
```
### How Nesting Works at the CRDT Level
Each space's `CommunityDoc` gains a `nestedSpaces` map alongside its existing `shapes` map:
```typescript
interface CommunityDoc {
meta: CommunityMeta;
shapes: { [id: string]: ShapeData };
members: { [did: string]: SpaceMember };
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
}
```
When a client renders a space, it:
1. Renders all local `shapes` as before
2. For each entry in `nestedSpaces`:
a. Opens a secondary WebSocket to the nested space's sync endpoint
b. Receives that space's shapes (filtered by `SpaceRefFilter` if set)
c. Renders them inside the `SpaceRef`'s bounding box on the canvas
d. Applies `NestPermissions` — e.g., if `write: false`, nested shapes are non-editable
This is the same mechanism planned for `folk-canvas` (Task-45), but elevated from a shape type to a first-class document field. The `folk-canvas` shape becomes the *renderer* for `SpaceRef` entries.
### Nesting Depth
There is no hard limit on nesting depth. A space can nest a space that nests a space. Each boundary applies its own `NestPermissions`, and the **permission cascade** (see below) ensures the most restrictive permission always wins.
```
alice.rspace.online
└── nestedSpaces:
├── ref-1 → dao.rspace.online (read + write)
│ └── nestedSpaces:
│ ├── ref-a → working-group (read + write)
│ │ └── nestedSpaces:
│ │ └── ref-x → alice.rspace.online/project-x (read-only)
│ │ ↑ circular reference is fine — alice
│ │ shared a filtered view of her own
│ │ space back into the working group
│ └── ref-b → treasury (read-only, rWallet shapes only)
└── ref-2 → art-collab.rspace.online (read-only)
```
### Circular References
A space nesting itself (directly or through a chain) is permitted. The renderer detects cycles and displays the nested instance as a static snapshot rather than a live recursive embed, preventing infinite recursion. The canonical data still syncs — only the rendering is bounded.
---
## Permission Cascade Model
### The Rule: Most Restrictive Wins (Intersection)
When a space is nested, the effective permissions at any depth are the **intersection** of all permissions along the nesting chain. No downstream nest can grant more access than its parent granted.
### Formal Definition
```
EffectivePermissions(depth N) = NestPermissions[1] ∩ NestPermissions[2] ∩ ... ∩ NestPermissions[N]
```
Where `∩` for each boolean field is logical AND:
```typescript
function cascadePermissions(chain: NestPermissions[]): NestPermissions {
return {
read: chain.every(p => p.read),
write: chain.every(p => p.write),
addShapes: chain.every(p => p.addShapes),
deleteShapes: chain.every(p => p.deleteShapes),
reshare: chain.every(p => p.reshare),
expiry: Math.min(...chain.filter(p => p.expiry).map(p => p.expiry!)) || undefined,
};
}
```
### Examples
**Example 1: Permission narrowing across depth**
```
Alice's space
└── nests DAO space (read: ✓, write: ✓, reshare: ✓)
└── nests Working Group (read: ✓, write: ✓, reshare: ✗)
└── nests Bob's space (read: ✓, write: ✗)
Effective permissions at each level:
Level 1 (DAO in Alice's view): read: ✓ write: ✓ reshare: ✓
Level 2 (WG in Alice's → DAO view): read: ✓ write: ✓ reshare: ✗ ← narrowed
Level 3 (Bob in Alice → DAO → WG): read: ✓ write: ✗ reshare: ✗ ← narrowed further
```
**Example 2: Revocation propagates**
```
Alice revokes the DAO nest from her space
→ DAO content disappears from Alice's canvas
→ Working Group content (nested inside DAO) also disappears
→ Bob's content (nested inside WG) also disappears
```
One action, clean propagation. Alice doesn't need to know the nesting depth.
**Example 3: reshare: false stops propagation**
```
Alice nests her project into DAO space with reshare: false
→ DAO members can see and interact with Alice's project
→ DAO members CANNOT nest Alice's project further into other spaces
→ If they try, the server rejects the SpaceRef creation
```
This is Alice's control over how far her content travels.
### Permission Enforcement Points
| Point | What's checked | Who checks |
|-------|---------------|------------|
| **SpaceRef creation** | 1. Source space's `nestPolicy.consent` — is this requester allowed? 2. Source space's `nestPolicy.blocklist/allowlist` 3. Requested permissions capped by `nestPolicy.defaultPermissions` 4. Does the creator have `reshare` permission if nesting an already-nested space? 5. Does the creator have `admin` or `moderator` role in the target? | Server, at `POST /api/spaces/:slug/nest` or approval flow |
| **WebSocket upgrade** | Does the connecting user have access to this space AND to all nested spaces in the chain? | Server, at WS handshake via `authenticateWSUpgrade()` |
| **Shape write** | Is the effective `write` permission `true` across the full nesting chain? Does the user's role in the *source* space permit this action? | Server, in WS message handler |
| **Shape read** | Is the effective `read` permission `true`? Does the user meet the source space's visibility requirements? | Server, when syncing nested space data |
| **Revocation** | Only the `SpaceRef` creator, an admin of the nesting space, or an admin of the *source* space can remove a `SpaceRef`. Source space admins can always revoke — this is the "pull the plug" guarantee. | Server, at `DELETE /api/spaces/:slug/nest/:refId` |
### Dual Authority: Nesting Permission + Space Role
A user's effective capability in a nested space is determined by TWO factors:
1. **The NestPermissions chain** — what the nesting allows at a structural level
2. **The user's role in the source space** — what the user is allowed to do in that space independently
Both must permit the action. If the nesting grants `write: true` but the user is only a `viewer` in the source space, they still can't write. If the user is an `admin` in the source space but the nesting says `write: false`, they still can't write *through the nest*.
```typescript
function canPerformAction(
action: 'read' | 'write' | 'addShapes' | 'deleteShapes',
nestChain: NestPermissions[],
userRoleInSource: SpaceRole,
): boolean {
const cascaded = cascadePermissions(nestChain);
// Nesting must allow it
if (!cascaded[action]) return false;
// User's role in the source space must also allow it
const requiredRole = actionToMinimumRole(action);
return roleRank(userRoleInSource) >= roleRank(requiredRole);
}
function actionToMinimumRole(action: string): SpaceRole {
switch (action) {
case 'read': return 'viewer';
case 'write': return 'participant';
case 'addShapes': return 'participant';
case 'deleteShapes': return 'moderator';
default: return 'admin';
}
}
```
---
## Identity-Space Binding
### Auto-Provisioning at Registration
When `POST /api/register/complete` succeeds in EncryptID, the server also:
1. Calls `createCommunity(username, username, did, 'members_only')`
2. Fires `onSpaceCreate(username)` for all registered modules
3. Enables the user's selected default modules (or a sensible default set)
4. The space is immediately available at `<username>.rspace.online`
```typescript
// In src/encryptid/server.ts — after successful registration
// Auto-provision user's space
const userSpace = await createCommunity(
username, // name
username, // slug (becomes <username>.rspace.online)
did, // ownerDID
'members_only', // sovereign by default
);
// Enable default modules
await setEnabledModules(username, DEFAULT_USER_MODULES);
// Fire module hooks
for (const mod of getAllModules()) {
if (mod.onSpaceCreate) {
await mod.onSpaceCreate(username);
}
}
```
### Default Module Set
New users get a sensible starting set. They can add or remove modules at any time.
```typescript
const DEFAULT_USER_MODULES = [
'canvas', // the canvas itself
'notes', // note-taking
'files', // file storage
'wallet', // identity-linked wallet
];
```
### Space Settings Stored in Meta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[]; // NEW — which modules are active in this space
description?: string; // NEW — optional space description
avatar?: string; // NEW — optional space avatar/icon URL
}
```
---
## Nest Consent & Notification
### The Problem
Without consent controls, anyone who can access a space could nest it into another space without the owner knowing. The `reshare` flag on `NestPermissions` controls whether *already-nested* content can be nested further — but it doesn't govern the *initial* nesting. The space owner needs to control whether their space can be nested at all, by whom, and whether they're notified.
### NestPolicy: Per-Space Inbound Nesting Rules
Every space has a `nestPolicy` in its metadata that governs how *other* spaces can nest it:
```typescript
interface NestPolicy {
// Who can nest this space into their space?
consent: 'open' | 'members' | 'approval' | 'closed';
// Should the space owner/admins be notified of nesting events?
notifications: NestNotifications;
// Default permissions granted when this space is nested
// (the nester can request less, but not more)
defaultPermissions: NestPermissions;
// Spaces that are always allowed to nest this one (bypass consent)
allowlist?: string[]; // slugs
// Spaces that are never allowed to nest this one
blocklist?: string[]; // slugs
}
interface NestNotifications {
onNestRequest: boolean; // notify when someone requests to nest this space
onNestCreated: boolean; // notify when nesting is established
onNestRevoked: boolean; // notify when a nesting is removed
onReshare: boolean; // notify when nested content is re-nested further
channel: 'inbox' | 'email' | 'both'; // where to send notifications
}
```
### Consent Levels
| Level | Behavior | Default for |
|-------|----------|-------------|
| `open` | Anyone with access to the space can nest it. No approval needed. | Public community spaces |
| `members` | Only members of this space can nest it into other spaces. | Collaborative workgroups |
| `approval` | Nesting creates a pending request. Space admin must approve before the nest becomes active. | Auto-provisioned user spaces |
| `closed` | No one can nest this space. It exists only at its own URL. | Sensitive/private spaces |
### How Consent Flows
**`open` — no friction:**
```
Bob nests alice.rspace.online into dao.rspace.online
→ SpaceRef created immediately
→ Alice notified (if notifications.onNestCreated: true)
```
**`members` — membership gate:**
```
Bob attempts to nest alice.rspace.online into dao.rspace.online
→ Server checks: is Bob a member of alice.rspace.online?
→ YES → SpaceRef created, Alice notified
→ NO → 403 Forbidden
```
**`approval` — request/approve flow:**
```
Bob requests to nest alice.rspace.online into dao.rspace.online
→ Server creates a PendingNestRequest (not a live SpaceRef yet)
→ Alice notified via her preferred channel
→ Alice reviews: sees who's requesting, which space, what permissions
→ Alice approves → SpaceRef created, Bob notified
→ Alice denies → request deleted, Bob notified
→ Alice can also modify the permissions before approving
(e.g., downgrade write: true to write: false)
```
**`closed` — hard block:**
```
Bob attempts to nest alice.rspace.online
→ 403 Forbidden, no request created
```
### PendingNestRequest
```typescript
interface PendingNestRequest {
id: string;
sourceSlug: string; // the space being nested (e.g., alice)
targetSlug: string; // where it's being nested into (e.g., dao)
requestedBy: string; // DID of requester
requestedPermissions: NestPermissions;
message?: string; // optional note from requester ("sharing my project notes")
status: 'pending' | 'approved' | 'denied';
createdAt: number;
resolvedAt?: number;
resolvedBy?: string; // DID of admin who approved/denied
modifiedPermissions?: NestPermissions; // if admin adjusted before approving
}
```
### Default Permissions Cap
The `defaultPermissions` in `NestPolicy` acts as a ceiling. When someone nests the space, they can request permissions *up to* this level, but not beyond:
```
Alice's nestPolicy.defaultPermissions:
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
Bob requests to nest Alice's space with write: true
→ Server caps it: write: false (Alice's default ceiling)
→ Bob's SpaceRef gets read: true, write: false
```
This means Alice can set her policy once — "my space can be read but not written to when nested" — and every future nesting respects it without her reviewing each request (unless she wants to, via `approval` consent).
### Allowlist & Blocklist
For spaces where `consent: 'approval'` is too much friction for trusted relationships but `open` is too permissive:
- **Allowlist**: These slugs bypass consent entirely. If `climate-dao` is on Alice's allowlist, the DAO can nest her space without approval.
- **Blocklist**: These slugs are always denied, even if consent is `open`. Overrides everything.
Blocklist takes priority over allowlist. If a slug appears in both, it's blocked.
### Notifications in Practice
Notifications are delivered through rSpace's existing channels:
- **`inbox`**: Appears in the user's rSpace inbox (the rInbox module). Non-intrusive, checked at the user's pace.
- **`email`**: Sent to the user's EncryptID-linked email via Mailcow. For important events.
- **`both`**: Both channels.
Example notification:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔗 Nest Request
@bob wants to nest your space into climate-dao.rspace.online
Requested permissions:
Read: ✓ Write: ✗ Reshare: ✗
Message: "Sharing your research notes with the funding working group"
[Approve] [Approve with changes] [Deny]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
### Updated CommunityMeta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[];
description?: string;
avatar?: string;
encrypted?: boolean;
encryptionKeyId?: string;
nestPolicy: NestPolicy; // NEW — governs inbound nesting
}
```
### Sensible Defaults
```typescript
// Auto-provisioned user space: sovereign by default
const DEFAULT_USER_NEST_POLICY: NestPolicy = {
consent: 'approval',
notifications: {
onNestRequest: true,
onNestCreated: true,
onNestRevoked: false,
onReshare: true,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: false,
addShapes: false,
deleteShapes: false,
reshare: false,
},
};
// Newly created community space: open collaboration
const DEFAULT_COMMUNITY_NEST_POLICY: NestPolicy = {
consent: 'members',
notifications: {
onNestRequest: false,
onNestCreated: true,
onNestRevoked: true,
onReshare: false,
channel: 'inbox',
},
defaultPermissions: {
read: true,
write: true,
addShapes: true,
deleteShapes: false,
reshare: true,
},
};
```
Note: these are not enforced by a `type` field. The auto-provisioned space gets `DEFAULT_USER_NEST_POLICY` at creation time, and a manually created space gets `DEFAULT_COMMUNITY_NEST_POLICY` — but the user can change either to anything. A "personal" space could be set to `open` and a "community" space could be set to `closed`. The defaults just encode sensible starting points.
### API Surface for Consent
```
GET /api/spaces/:slug/nest-policy Get space's nest policy
PATCH /api/spaces/:slug/nest-policy Update nest policy (admin only)
POST /api/spaces/:slug/nest-requests Create a pending nest request
GET /api/spaces/:slug/nest-requests List pending requests (admin only)
GET /api/spaces/:slug/nest-requests/:id Get a specific request
PATCH /api/spaces/:slug/nest-requests/:id Approve/deny (admin only)
```
When a request is approved, the server automatically creates the `SpaceRef` in the target space's `nestedSpaces` map with the (potentially modified) permissions.
---
## How Sharing Actually Works
### Scenario: Alice shares her project notes into a DAO
1. Alice has shapes on her canvas at `alice.rspace.online` — notes, a budget, a timeline.
2. Alice is a member of `climate-dao.rspace.online`.
3. Alice opens her space settings or right-clicks a group of shapes → "Nest into space..."
4. She selects `climate-dao.rspace.online` and sets permissions:
- `read: true` — DAO members can see the shapes
- `write: false` — only Alice can edit them (they're her canonical data)
- `reshare: false` — the DAO can't nest these further
5. Server creates a `SpaceRef` in `climate-dao`'s `nestedSpaces` map, pointing to `alice` with the chosen filter and permissions.
6. DAO members visiting `climate-dao.rspace.online/canvas` now see Alice's shapes rendered inside a bordered region on the canvas.
7. The shapes are live — if Alice updates them on her space, the DAO sees the update in real time.
### Scenario: Alice revokes
1. Alice opens her space settings → sees "Shared into: climate-dao"
2. Clicks revoke.
3. Server removes the `SpaceRef` from `climate-dao`'s `nestedSpaces` map.
4. The shapes vanish from the DAO canvas. The DAO's Automerge doc is unchanged — it never contained Alice's shapes directly, only a reference.
5. Alice's shapes remain on her own canvas, untouched.
### Scenario: DAO nests a working group
1. `climate-dao.rspace.online` creates a new space: `climate-dao-wg-funding` (or it could be at `funding.climate-dao.rspace.online` if wildcard subdomains are supported)
2. The DAO admin nests the working group into the DAO space with `read: true, write: true, reshare: true`
3. Working group members can also be members of the DAO — their role in each space is independent
4. Content created in the working group space appears on both the working group canvas AND the DAO canvas (via the nest)
5. Alice's content that was nested into the DAO with `reshare: false` does NOT appear in the working group — `reshare: false` blocked further nesting
### Scenario: Browsing nested spaces
When viewing a canvas with nested spaces, the user sees:
```
┌─────────────────────────────────────────────────────────────┐
│ climate-dao.rspace.online/canvas │
│ │
│ [folk-note] [folk-budget] [folk-poll] │
│ │
│ ┌──────────────────────────────┐ │
│ │ ≡ alice.rspace.online │ ← nested space border │
│ │ (shared by @alice, read-only) │
│ │ │ │
│ │ [folk-note] [folk-timeline] │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ ≡ funding working group │ │
│ │ (read + write) │ │
│ │ │ │
│ │ [folk-budget] [folk-note] │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
Each nested region is:
- Visually bounded (border, header with source space name)
- Scrollable/zoomable independently (it's a canvas within a canvas)
- Badged with the source and permission level
- Clickable to "enter" — navigating to the full nested space
---
## Encryption & Nested Spaces
### At-Rest Encryption
Each space's Automerge document can optionally be encrypted at rest using the owner's EncryptID Layer 2 AES-256 key:
```typescript
interface CommunityMeta {
// ... existing fields ...
encrypted: boolean; // is this space's document encrypted at rest?
encryptionKeyId?: string; // which key was used (for key rotation)
}
```
For the user's own space, this is straightforward — encrypt with their derived key, decrypt on their device.
### Sharing Encrypted Content via Nesting
When an encrypted space is nested into another space, the nested content must be readable by the parent space's members. Two approaches:
**Approach A: Re-encryption at the boundary**
The space owner re-encrypts the shared subset of shapes with a shared key that the parent space's members can access. This key is distributed via the parent space's membership (each member's public key encrypts a copy of the shared key).
**Approach B: Plaintext projection**
The server (which must hold decryption capability for sync purposes, or the owner's client does) creates a plaintext projection of the filtered shapes and serves that to the parent space's WebSocket. The canonical encrypted version stays in the source space.
**Approach C: Client-side decryption with delegated keys**
The source space owner wraps their content key with each authorized viewer's public key. Authorized clients decrypt on their device. This is true E2E but adds complexity to the nesting chain.
**Recommendation:** Start with Approach B (plaintext projection, server-mediated) for simplicity. Graduate to Approach C (delegated keys) for spaces that require E2E encryption. The `encrypted` flag on `CommunityMeta` determines which path is used.
---
## Schema Changes Summary
### CommunityDoc (Automerge)
```typescript
interface CommunityDoc {
meta: CommunityMeta;
shapes: { [id: string]: ShapeData };
members: { [did: string]: SpaceMember };
nestedSpaces: { [refId: string]: SpaceRef }; // NEW
}
```
### CommunityMeta
```typescript
interface CommunityMeta {
name: string;
slug: string;
createdAt: string;
visibility: SpaceVisibility;
ownerDID: string | null;
enabledModules: string[]; // NEW
description?: string; // NEW
avatar?: string; // NEW
encrypted?: boolean; // NEW
encryptionKeyId?: string; // NEW
}
```
### New Types
```typescript
interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string; // DID of who created this nesting
}
interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number;
}
```
### No Changes To
- `ShapeData` — shapes remain the same. They don't know or care whether they're being viewed directly or through a nest.
- `SpaceMember` — membership is per-space, unchanged.
- `SpaceVisibility` — the four levels remain. They govern direct access to the space; nesting adds a second access path that still must satisfy visibility.
---
## API Surface
### New Endpoints
```
POST /api/spaces/:slug/nest Create a SpaceRef (nest a space)
GET /api/spaces/:slug/nest List all nested space refs
GET /api/spaces/:slug/nest/:refId Get a specific SpaceRef
PATCH /api/spaces/:slug/nest/:refId Update SpaceRef (permissions, filter, position)
DELETE /api/spaces/:slug/nest/:refId Remove a SpaceRef (un-nest)
GET /api/spaces/:slug/nested-in List all spaces this space is nested into
(so the owner can see where their content appears)
```
### WebSocket Protocol Additions
```
Message Type Direction Purpose
─────────────────────────────────────────────────────────────
nest-sync Server→Client Sync data from a nested space
nest-subscribe Client→Server Subscribe to a specific nested space's updates
nest-unsubscribe Client→Server Unsubscribe from a nested space
nest-permission-check Client→Server Check effective permissions for an action in a nest
```
### Modified Endpoints
```
POST /api/register/complete Now also provisions <username>.rspace.online
GET /api/spaces Returns user's own space first, pinned
```
---
## Implementation Phases
### Phase 1: Schema & Auto-Provisioning
- Add `enabledModules`, `description`, `avatar`, `nestPolicy` to `CommunityMeta`
- Add `nestedSpaces` map to `CommunityDoc`
- Add auto-provisioning in EncryptID registration flow (with `DEFAULT_USER_NEST_POLICY`)
- Add subdomain routing for `<username>.rspace.online`
### Phase 2: Nesting CRUD & Consent
- Implement `SpaceRef` creation, update, deletion endpoints
- Implement `nested-in` reverse lookup
- Implement `nestPolicy` CRUD (get/update)
- Implement consent flow: `open` (immediate), `members` (membership gate), `approval` (request/approve/deny), `closed` (block)
- Implement `PendingNestRequest` lifecycle (create, list, approve, deny)
- Implement `defaultPermissions` ceiling on requested permissions
- Implement allowlist/blocklist evaluation
- Permission validation on nest creation (consent + reshare check + role check)
- Notification dispatch (inbox module integration)
### Phase 3: Nested Space Rendering
- Build `folk-canvas` shape (Task-45) as the renderer for `SpaceRef` entries
- Implement secondary WebSocket connections for nested space data
- Render nested spaces as bordered, labeled regions on the canvas
- Handle collapsed vs. expanded views
### Phase 4: Permission Cascade
- Implement `cascadePermissions()` across nesting chains
- Enforce dual authority (nest permissions + space role) on all write operations
- Implement revocation propagation
- Add `reshare: false` enforcement
### Phase 5: Encryption Integration (Approach B — Server-Mediated)
- `encrypted` and `encryptionKeyId` flags on `CommunityMeta` — DONE
- AES-256-GCM encryption at rest for Automerge documents — DONE
- Custom file format: magic bytes `rSEN` + keyId length + keyId + IV + ciphertext
- Transparent encrypt-on-save, decrypt-on-load in community-store
- Key derivation via HMAC-SHA256 from server secret + keyId (placeholder for EncryptID L2)
- API endpoints: `GET/PATCH /api/spaces/:slug/encryption` — DONE
- Plaintext projection for nested views: server decrypts and serves via WS — inherent in Approach B
- Future: EncryptID Layer 2 client-side key delegation (Approach C) for true E2E encryption
---
## Open Questions
1. **Subdomain wildcards** — Should `working-group.climate-dao.rspace.online` be supported for hierarchical subdomain nesting? Or keep it flat with `climate-dao-wg-funding.rspace.online`?
2. **Offline nesting** — When offline, should the client cache nested space content from the last sync? How stale can nested content be before we show a warning?
3. ~~**Notification of nesting**~~**RESOLVED**: See *Nest Consent & Notification* section. Space owners configure consent level (`open`/`members`/`approval`/`closed`) and notification preferences in their space's `nestPolicy`. Auto-provisioned user spaces default to `approval` consent with inbox notifications.
4. **Billing/quotas** — Does each `<username>.rspace.online` space count against storage? Are there limits on how many spaces a user can create or how deeply they can nest?
5. **Presence across nests** — When Alice is editing shapes in her space that are visible via nesting in the DAO space, do DAO members see Alice's presence cursor? Or only direct space visitors?
6. **Nest request expiry** — Should pending nest requests expire after a period of inactivity? (e.g., 30 days with no response → auto-denied and cleaned up)
7. **Bulk consent** — Should there be a way to approve/deny nest requests in bulk? DAOs with many members nesting content could generate a lot of requests if set to `approval`.
---
*This document describes the target architecture. Implementation should proceed incrementally through the phases above, with each phase usable independently.*

65
entrypoint.sh Normal file
View File

@ -0,0 +1,65 @@
#!/bin/sh
# Infisical secret injection entrypoint
# Fetches secrets from Infisical API and injects them as env vars before starting the app.
# Required env vars: INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET
# Optional: INFISICAL_PROJECT_SLUG (default: rspace), INFISICAL_ENV (default: prod),
# INFISICAL_URL (default: http://infisical:8080)
set -e
INFISICAL_URL="${INFISICAL_URL:-http://infisical:8080}"
INFISICAL_ENV="${INFISICAL_ENV:-prod}"
INFISICAL_PROJECT_SLUG="${INFISICAL_PROJECT_SLUG:-rspace}"
if [ -z "$INFISICAL_CLIENT_ID" ] || [ -z "$INFISICAL_CLIENT_SECRET" ]; then
echo "[infisical] No credentials set, starting without secret injection"
exec "$@"
fi
echo "[infisical] Fetching secrets from ${INFISICAL_PROJECT_SLUG}/${INFISICAL_ENV}..."
# Use Bun's built-in fetch API for HTTP calls and JSON parsing
EXPORTS=$(bun -e "
(async () => {
try {
const base = process.env.INFISICAL_URL;
const auth = await fetch(base + '/api/v1/auth/universal-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: process.env.INFISICAL_CLIENT_ID,
clientSecret: process.env.INFISICAL_CLIENT_SECRET
})
}).then(r => r.json());
if (!auth.accessToken) { console.error('[infisical] Auth failed'); process.exit(1); }
const slug = process.env.INFISICAL_PROJECT_SLUG;
const env = process.env.INFISICAL_ENV;
const secrets = await fetch(base + '/api/v3/secrets/raw?workspaceSlug=' + slug + '&environment=' + env + '&secretPath=/&recursive=true', {
headers: { 'Authorization': 'Bearer ' + auth.accessToken }
}).then(r => r.json());
if (!secrets.secrets) { console.error('[infisical] No secrets returned'); process.exit(1); }
for (const s of secrets.secrets) {
const escaped = s.secretValue.replace(/'/g, \"'\\\\''\" );
console.log('export ' + s.secretKey + \"='\" + escaped + \"'\");
}
} catch (e) { console.error('[infisical] Error:', e.message); process.exit(1); }
})();
" 2>&1) || {
echo "[infisical] WARNING: Failed to fetch secrets, starting with existing env vars"
exec "$@"
}
# Check if we got export statements or error messages
if echo "$EXPORTS" | grep -q "^export "; then
COUNT=$(echo "$EXPORTS" | grep -c "^export ")
eval "$EXPORTS"
echo "[infisical] Injected ${COUNT} secrets"
else
echo "[infisical] WARNING: $EXPORTS"
echo "[infisical] Starting with existing env vars"
fi
exec "$@"

View File

@ -1,6 +1,7 @@
import * as Automerge from "@automerge/automerge";
import type { FolkShape } from "./folk-shape";
import type { OfflineStore } from "./offline-store";
import type { Layer, LayerFlow } from "./layer-types";
// Shape data stored in Automerge document
export interface ShapeData {
@ -28,16 +29,69 @@ export interface ShapeData {
[key: string]: unknown;
}
// ── Nested space types (client-side) ──
export interface NestPermissions {
read: boolean;
write: boolean;
addShapes: boolean;
deleteShapes: boolean;
reshare: boolean;
expiry?: number;
}
export interface SpaceRefFilter {
shapeTypes?: string[];
shapeIds?: string[];
tags?: string[];
moduleIds?: string[];
}
export interface SpaceRef {
id: string;
sourceSlug: string;
sourceDID?: string;
filter?: SpaceRefFilter;
x: number;
y: number;
width: number;
height: number;
rotation: number;
permissions: NestPermissions;
collapsed?: boolean;
label?: string;
createdAt: number;
createdBy: string;
}
// Automerge document structure
export interface CommunityDoc {
meta: {
name: string;
slug: string;
createdAt: string;
enabledModules?: string[];
description?: string;
avatar?: string;
};
shapes: {
[id: string]: ShapeData;
};
nestedSpaces?: {
[refId: string]: SpaceRef;
};
/** Tab/layer system — each layer is an rApp page in this space */
layers?: {
[id: string]: Layer;
};
/** Inter-layer flows (economic, trust, data, etc.) */
flows?: {
[id: string]: LayerFlow;
};
/** Currently active layer ID */
activeLayerId?: string;
/** Layer view mode: flat (tabs) or stack (side view) */
layerViewMode?: "flat" | "stack";
}
type SyncState = Automerge.SyncState;
@ -696,6 +750,16 @@ export class CommunitySync extends EventTarget {
if (data.rankings !== undefined) rank.rankings = data.rankings;
}
// Update nested canvas properties
if (data.type === "folk-canvas") {
const canvas = shape as any;
if (data.sourceSlug !== undefined && canvas.sourceSlug !== data.sourceSlug) canvas.sourceSlug = data.sourceSlug;
if (data.sourceDID !== undefined && canvas.sourceDID !== data.sourceDID) canvas.sourceDID = data.sourceDID;
if (data.permissions !== undefined) canvas.permissions = data.permissions;
if (data.collapsed !== undefined && canvas.collapsed !== data.collapsed) canvas.collapsed = data.collapsed;
if (data.label !== undefined && canvas.label !== data.label) canvas.label = data.label;
}
// Update choice-spider properties
if (data.type === "folk-choice-spider") {
const spider = shape as any;
@ -705,6 +769,25 @@ export class CommunitySync extends EventTarget {
if (data.scores !== undefined) spider.scores = data.scores;
}
// Update rApp embed properties
if (data.type === "folk-rapp") {
const rapp = shape as any;
if (data.moduleId !== undefined && rapp.moduleId !== data.moduleId) rapp.moduleId = data.moduleId;
if (data.spaceSlug !== undefined && rapp.spaceSlug !== data.spaceSlug) rapp.spaceSlug = data.spaceSlug;
}
// Update feed shape properties
if (data.type === "folk-feed") {
const feed = shape as any;
if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer;
if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule;
if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId;
if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind;
if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter;
if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems;
if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval;
}
// Update social-post properties
if (data.type === "folk-social-post") {
const post = shape as any;
@ -778,6 +861,130 @@ export class CommunitySync extends EventTarget {
}
}
// ── Layer & Flow API ──
/** Add a layer to the document */
addLayer(layer: Layer): void {
this.#doc = Automerge.change(this.#doc, `Add layer ${layer.id}`, (doc) => {
if (!doc.layers) doc.layers = {};
doc.layers[layer.id] = layer;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("layer-added", { detail: layer }));
}
/** Remove a layer */
removeLayer(layerId: string): void {
this.#doc = Automerge.change(this.#doc, `Remove layer ${layerId}`, (doc) => {
if (doc.layers && doc.layers[layerId]) {
delete doc.layers[layerId];
}
// Remove flows connected to this layer
if (doc.flows) {
for (const [fid, flow] of Object.entries(doc.flows)) {
if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) {
delete doc.flows[fid];
}
}
}
// If active layer was removed, switch to first remaining
if (doc.activeLayerId === layerId) {
const remaining = doc.layers ? Object.keys(doc.layers) : [];
doc.activeLayerId = remaining[0] || "";
}
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } }));
}
/** Update a layer's properties */
updateLayer(layerId: string, updates: Partial<Layer>): void {
this.#doc = Automerge.change(this.#doc, `Update layer ${layerId}`, (doc) => {
if (doc.layers && doc.layers[layerId]) {
for (const [key, value] of Object.entries(updates)) {
(doc.layers[layerId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Set active layer */
setActiveLayer(layerId: string): void {
this.#doc = Automerge.change(this.#doc, `Switch to layer ${layerId}`, (doc) => {
doc.activeLayerId = layerId;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } }));
}
/** Set layer view mode */
setLayerViewMode(mode: "flat" | "stack"): void {
this.#doc = Automerge.change(this.#doc, `Set view mode ${mode}`, (doc) => {
doc.layerViewMode = mode;
});
this.#scheduleSave();
this.#syncToServer();
}
/** Add a flow between layers */
addFlow(flow: LayerFlow): void {
this.#doc = Automerge.change(this.#doc, `Add flow ${flow.id}`, (doc) => {
if (!doc.flows) doc.flows = {};
doc.flows[flow.id] = flow;
});
this.#scheduleSave();
this.#syncToServer();
this.dispatchEvent(new CustomEvent("flow-added", { detail: flow }));
}
/** Remove a flow */
removeFlow(flowId: string): void {
this.#doc = Automerge.change(this.#doc, `Remove flow ${flowId}`, (doc) => {
if (doc.flows && doc.flows[flowId]) {
delete doc.flows[flowId];
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Update flow properties */
updateFlow(flowId: string, updates: Partial<LayerFlow>): void {
this.#doc = Automerge.change(this.#doc, `Update flow ${flowId}`, (doc) => {
if (doc.flows && doc.flows[flowId]) {
for (const [key, value] of Object.entries(updates)) {
(doc.flows[flowId] as unknown as Record<string, unknown>)[key] = value;
}
}
});
this.#scheduleSave();
this.#syncToServer();
}
/** Get all layers (sorted by order) */
getLayers(): Layer[] {
const layers = this.#doc.layers || {};
return Object.values(layers).sort((a, b) => a.order - b.order);
}
/** Get all flows */
getFlows(): LayerFlow[] {
const flows = this.#doc.flows || {};
return Object.values(flows);
}
/** Get flows for a specific layer (as source or target) */
getFlowsForLayer(layerId: string): LayerFlow[] {
return this.getFlows().filter(
f => f.sourceLayerId === layerId || f.targetLayerId === layerId
);
}
/**
* Disconnect from server
*/

546
lib/folk-canvas.ts Normal file
View File

@ -0,0 +1,546 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import type { ShapeData, SpaceRef, NestPermissions } from "./community-sync";
const styles = css`
:host {
background: #f8fafc;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
min-width: 300px;
min-height: 200px;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
background: #334155;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
gap: 8px;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.header-left .icon {
flex-shrink: 0;
}
.source-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.header-right {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.badge {
font-size: 10px;
padding: 2px 6px;
border-radius: 9999px;
font-weight: 500;
white-space: nowrap;
}
.badge-read {
background: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.badge-write {
background: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
line-height: 1;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
width: 100%;
height: calc(100% - 32px);
position: relative;
overflow: auto;
}
.nested-canvas {
position: relative;
width: 100%;
height: 100%;
min-height: 150px;
}
.nested-shape {
position: absolute;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 8px;
font-size: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.nested-shape .shape-type {
font-size: 10px;
color: #94a3b8;
margin-bottom: 4px;
}
.nested-shape .shape-content {
color: #334155;
word-break: break-word;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background: #f1f5f9;
border-top: 1px solid #e2e8f0;
font-size: 10px;
color: #64748b;
}
.status-indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 4px;
}
.status-connected { background: #22c55e; }
.status-connecting { background: #eab308; }
.status-disconnected { background: #ef4444; }
.collapsed-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 32px);
padding: 16px;
text-align: center;
gap: 8px;
}
.collapsed-icon {
font-size: 32px;
opacity: 0.5;
}
.collapsed-label {
font-size: 13px;
color: #475569;
font-weight: 500;
}
.collapsed-meta {
font-size: 11px;
color: #94a3b8;
}
.enter-btn {
margin-top: 8px;
background: #334155;
color: white;
border: none;
border-radius: 6px;
padding: 6px 16px;
cursor: pointer;
font-size: 12px;
}
.enter-btn:hover {
background: #1e293b;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-canvas": FolkCanvas;
}
}
export class FolkCanvas extends FolkShape {
static override tagName = "folk-canvas";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#sourceSlug: string = "";
#parentSlug: string = ""; // slug of the space this shape lives in (for nest-from context)
#permissions: NestPermissions = {
read: true, write: false, addShapes: false, deleteShapes: false, reshare: false
};
#collapsed = false;
#label: string | null = null;
#sourceDID: string | null = null;
// WebSocket connection to nested space
#ws: WebSocket | null = null;
#connectionStatus: "disconnected" | "connecting" | "connected" = "disconnected";
#nestedShapes: Map<string, ShapeData> = new Map();
#reconnectAttempts = 0;
#maxReconnectAttempts = 5;
// DOM refs
#nestedCanvasEl: HTMLElement | null = null;
#statusIndicator: HTMLElement | null = null;
#statusText: HTMLElement | null = null;
#shapeCountEl: HTMLElement | null = null;
get sourceSlug() { return this.#sourceSlug; }
set sourceSlug(value: string) {
if (this.#sourceSlug === value) return;
this.#sourceSlug = value;
this.requestUpdate("sourceSlug");
this.dispatchEvent(new CustomEvent("content-change", { detail: { sourceSlug: value } }));
// Reconnect to new source
this.#disconnect();
if (value) this.#connectToSource();
}
get permissions(): NestPermissions { return this.#permissions; }
set permissions(value: NestPermissions) {
this.#permissions = value;
this.requestUpdate("permissions");
}
get collapsed() { return this.#collapsed; }
set collapsed(value: boolean) {
this.#collapsed = value;
this.requestUpdate("collapsed");
this.#renderView();
}
get label() { return this.#label; }
set label(value: string | null) {
this.#label = value;
this.requestUpdate("label");
}
get sourceDID() { return this.#sourceDID; }
set sourceDID(value: string | null) { this.#sourceDID = value; }
get parentSlug() { return this.#parentSlug; }
set parentSlug(value: string) { this.#parentSlug = value; }
override createRenderRoot() {
const root = super.createRenderRoot();
// Read initial attributes
this.#sourceSlug = this.getAttribute("source-slug") || "";
this.#label = this.getAttribute("label") || null;
this.#collapsed = this.getAttribute("collapsed") === "true";
const wrapper = document.createElement("div");
wrapper.style.cssText = "width: 100%; height: 100%; display: flex; flex-direction: column;";
wrapper.innerHTML = html`
<div class="header" data-drag>
<div class="header-left">
<span class="icon">\u{1F5BC}</span>
<span class="source-name"></span>
</div>
<div class="header-right">
<span class="permission-badge badge"></span>
<div class="header-actions">
<button class="collapse-btn" title="Toggle collapse">\u25BC</button>
<button class="enter-space-btn" title="Open space">\u2197</button>
<button class="close-btn" title="Remove">\u00D7</button>
</div>
</div>
</div>
<div class="content">
<div class="nested-canvas"></div>
</div>
<div class="status-bar">
<span><span class="status-indicator status-disconnected"></span><span class="status-text">Disconnected</span></span>
<span class="shape-count">0 shapes</span>
</div>
`;
// Replace the slot container
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
// Cache DOM refs
this.#nestedCanvasEl = wrapper.querySelector(".nested-canvas");
this.#statusIndicator = wrapper.querySelector(".status-indicator");
this.#statusText = wrapper.querySelector(".status-text");
this.#shapeCountEl = wrapper.querySelector(".shape-count");
const sourceNameEl = wrapper.querySelector(".source-name") as HTMLElement;
const permBadge = wrapper.querySelector(".permission-badge") as HTMLElement;
const collapseBtn = wrapper.querySelector(".collapse-btn") as HTMLButtonElement;
const enterBtn = wrapper.querySelector(".enter-space-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Set header text
sourceNameEl.textContent = this.#label || this.#sourceSlug || "Nested Space";
// Permission badge
if (this.#permissions.write) {
permBadge.textContent = "read + write";
permBadge.className = "badge badge-write";
} else {
permBadge.textContent = "read-only";
permBadge.className = "badge badge-read";
}
// Collapse toggle
collapseBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.collapsed = !this.#collapsed;
collapseBtn.textContent = this.#collapsed ? "\u25B6" : "\u25BC";
this.dispatchEvent(new CustomEvent("content-change", { detail: { collapsed: this.#collapsed } }));
});
// Enter space (navigate)
enterBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#sourceSlug) {
window.open(`/${this.#sourceSlug}/canvas`, "_blank");
}
});
// Close (remove nesting)
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Prevent drag on interactive content
const content = wrapper.querySelector(".content") as HTMLElement;
content.addEventListener("pointerdown", (e) => e.stopPropagation());
// Connect to the nested space
if (this.#sourceSlug && !this.#collapsed) {
this.#connectToSource();
}
return root;
}
#connectToSource(): void {
if (!this.#sourceSlug || this.#connectionStatus === "connected" || this.#connectionStatus === "connecting") return;
this.#setStatus("connecting");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const nestParam = this.#parentSlug ? `&nest-from=${encodeURIComponent(this.#parentSlug)}` : "";
const wsUrl = `${protocol}//${window.location.host}/ws/${this.#sourceSlug}?mode=json${nestParam}`;
this.#ws = new WebSocket(wsUrl);
this.#ws.onopen = () => {
this.#connectionStatus = "connected";
this.#reconnectAttempts = 0;
this.#setStatus("connected");
};
this.#ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === "snapshot" && msg.shapes) {
this.#nestedShapes.clear();
for (const [id, shape] of Object.entries(msg.shapes)) {
const s = shape as ShapeData;
if (!s.forgotten) {
this.#nestedShapes.set(id, s);
}
}
this.#renderNestedShapes();
}
} catch (e) {
console.error("[FolkCanvas] Failed to handle message:", e);
}
};
this.#ws.onclose = () => {
this.#connectionStatus = "disconnected";
this.#setStatus("disconnected");
this.#attemptReconnect();
};
this.#ws.onerror = () => {
this.#setStatus("disconnected");
};
}
#attemptReconnect(): void {
if (this.#reconnectAttempts >= this.#maxReconnectAttempts) return;
this.#reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.#reconnectAttempts - 1), 16000);
setTimeout(() => {
if (this.#connectionStatus === "disconnected") {
this.#connectToSource();
}
}, delay);
}
#disconnect(): void {
if (this.#ws) {
this.#ws.onclose = null; // prevent reconnect
this.#ws.close();
this.#ws = null;
}
this.#connectionStatus = "disconnected";
this.#nestedShapes.clear();
this.#setStatus("disconnected");
}
#setStatus(status: "disconnected" | "connecting" | "connected"): void {
this.#connectionStatus = status;
if (this.#statusIndicator) {
this.#statusIndicator.className = `status-indicator status-${status}`;
}
if (this.#statusText) {
this.#statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
}
}
#renderView(): void {
if (!this.shadowRoot) return;
const content = this.shadowRoot.querySelector(".content") as HTMLElement;
const statusBar = this.shadowRoot.querySelector(".status-bar") as HTMLElement;
if (!content || !statusBar) return;
if (this.#collapsed) {
content.innerHTML = `
<div class="collapsed-view">
<div class="collapsed-icon">\u{1F5BC}</div>
<div class="collapsed-label">${this.#label || this.#sourceSlug}</div>
<div class="collapsed-meta">${this.#nestedShapes.size} shapes</div>
<button class="enter-btn">Open space \u2192</button>
</div>
`;
statusBar.style.display = "none";
const enterBtn = content.querySelector(".enter-btn");
enterBtn?.addEventListener("click", () => {
if (this.#sourceSlug) window.open(`/${this.#sourceSlug}/canvas`, "_blank");
});
// Disconnect when collapsed
this.#disconnect();
} else {
content.innerHTML = `<div class="nested-canvas"></div>`;
this.#nestedCanvasEl = content.querySelector(".nested-canvas");
statusBar.style.display = "flex";
// Reconnect when expanded
if (this.#sourceSlug) this.#connectToSource();
this.#renderNestedShapes();
}
}
#renderNestedShapes(): void {
if (!this.#nestedCanvasEl) return;
this.#nestedCanvasEl.innerHTML = "";
if (this.#nestedShapes.size === 0) {
this.#nestedCanvasEl.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;height:100%;color:#94a3b8;font-size:12px;">
${this.#connectionStatus === "connected" ? "Empty space" : "Connecting..."}
</div>
`;
}
// Calculate bounding box of all shapes to fit within our viewport
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const shape of this.#nestedShapes.values()) {
minX = Math.min(minX, shape.x);
minY = Math.min(minY, shape.y);
maxX = Math.max(maxX, shape.x + (shape.width || 300));
maxY = Math.max(maxY, shape.y + (shape.height || 200));
}
const contentWidth = maxX - minX || 1;
const contentHeight = maxY - minY || 1;
const canvasWidth = this.#nestedCanvasEl.clientWidth || 600;
const canvasHeight = this.#nestedCanvasEl.clientHeight || 400;
const scale = Math.min(canvasWidth / contentWidth, canvasHeight / contentHeight, 1) * 0.9;
const offsetX = (canvasWidth - contentWidth * scale) / 2;
const offsetY = (canvasHeight - contentHeight * scale) / 2;
for (const shape of this.#nestedShapes.values()) {
const el = document.createElement("div");
el.className = "nested-shape";
el.style.left = `${offsetX + (shape.x - minX) * scale}px`;
el.style.top = `${offsetY + (shape.y - minY) * scale}px`;
el.style.width = `${(shape.width || 300) * scale}px`;
el.style.height = `${(shape.height || 200) * scale}px`;
const typeLabel = shape.type.replace("folk-", "").replace(/-/g, " ");
const content = shape.content
? shape.content.slice(0, 100) + (shape.content.length > 100 ? "..." : "")
: "";
el.innerHTML = `
<div class="shape-type">${typeLabel}</div>
<div class="shape-content">${content}</div>
`;
this.#nestedCanvasEl.appendChild(el);
}
// Update shape count
if (this.#shapeCountEl) {
this.#shapeCountEl.textContent = `${this.#nestedShapes.size} shape${this.#nestedShapes.size !== 1 ? "s" : ""}`;
}
}
disconnectedCallback(): void {
this.#disconnect();
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-canvas",
sourceSlug: this.#sourceSlug,
sourceDID: this.#sourceDID,
permissions: this.#permissions,
collapsed: this.#collapsed,
label: this.#label,
};
}
}

887
lib/folk-feed.ts Normal file
View File

@ -0,0 +1,887 @@
/**
* <folk-feed> Canvas shape that renders a live feed from another layer.
*
* Bridges layers by pulling data from a source layer's module API endpoint
* and rendering it as a live, updating feed within the current canvas.
*
* Attributes:
* source-layer source layer ID
* source-module source module ID (e.g. "notes", "funds", "vote")
* feed-id which feed to pull (e.g. "recent-notes", "proposals")
* flow-kind flow type for visual styling ("economic", "trust", "data", etc.)
* feed-filter optional JSON filter string
* max-items max items to display (default 10)
* refresh-interval auto-refresh ms (default 30000, 0 = manual only)
*
* The shape auto-fetches from /{space}/{source-module}/api/{feed-endpoint}
* and renders results as a scrollable card list.
*/
import { FolkShape } from "./folk-shape";
import { FLOW_COLORS, FLOW_LABELS } from "./layer-types";
import type { FlowKind } from "./layer-types";
export class FolkFeed extends FolkShape {
static tagName = "folk-feed";
#feedData: any[] = [];
#loading = false;
#error: string | null = null;
#refreshTimer: ReturnType<typeof setInterval> | null = null;
#inner: HTMLElement | null = null;
#editingIndex: number | null = null;
static get observedAttributes() {
return [
...FolkShape.observedAttributes,
"source-layer", "source-module", "feed-id", "flow-kind",
"feed-filter", "max-items", "refresh-interval",
];
}
get sourceLayer(): string { return this.getAttribute("source-layer") || ""; }
set sourceLayer(v: string) { this.setAttribute("source-layer", v); }
get sourceModule(): string { return this.getAttribute("source-module") || ""; }
set sourceModule(v: string) { this.setAttribute("source-module", v); }
get feedId(): string { return this.getAttribute("feed-id") || ""; }
set feedId(v: string) { this.setAttribute("feed-id", v); }
get flowKind(): FlowKind { return (this.getAttribute("flow-kind") as FlowKind) || "data"; }
set flowKind(v: FlowKind) { this.setAttribute("flow-kind", v); }
get feedFilter(): string { return this.getAttribute("feed-filter") || ""; }
set feedFilter(v: string) { this.setAttribute("feed-filter", v); }
get maxItems(): number { return parseInt(this.getAttribute("max-items") || "10", 10); }
set maxItems(v: number) { this.setAttribute("max-items", String(v)); }
get refreshInterval(): number { return parseInt(this.getAttribute("refresh-interval") || "30000", 10); }
set refreshInterval(v: number) { this.setAttribute("refresh-interval", String(v)); }
connectedCallback() {
super.connectedCallback();
this.#buildUI();
this.#fetchFeed();
this.#startAutoRefresh();
}
disconnectedCallback() {
super.disconnectedCallback();
this.#stopAutoRefresh();
}
attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) {
super.attributeChangedCallback(name, oldVal, newVal);
if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) {
this.#fetchFeed();
}
if (name === "refresh-interval") {
this.#stopAutoRefresh();
this.#startAutoRefresh();
}
if (name === "flow-kind") {
this.#updateHeader();
}
}
// ── Build the inner UI ──
#buildUI() {
if (this.#inner) return;
this.#inner = document.createElement("div");
this.#inner.className = "folk-feed-inner";
this.#inner.innerHTML = `
<div class="feed-header">
<div class="feed-kind-dot"></div>
<div class="feed-title"></div>
<button class="feed-navigate" title="Go to source layer"></button>
<button class="feed-refresh" title="Refresh"></button>
</div>
<div class="feed-items"></div>
<div class="feed-edit-overlay"></div>
<div class="feed-status"></div>
`;
const style = document.createElement("style");
style.textContent = FEED_STYLES;
this.#inner.prepend(style);
this.appendChild(this.#inner);
// Refresh button
this.#inner.querySelector(".feed-refresh")?.addEventListener("click", (e) => {
e.stopPropagation();
this.#fetchFeed();
});
// Navigate to source layer
this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.sourceModule) {
const space = this.#getSpaceSlug();
window.location.href = `/${space}/${this.sourceModule}`;
}
});
this.#updateHeader();
}
#updateHeader() {
if (!this.#inner) return;
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
const label = FLOW_LABELS[this.flowKind] || "Feed";
const dot = this.#inner.querySelector<HTMLElement>(".feed-kind-dot");
const title = this.#inner.querySelector<HTMLElement>(".feed-title");
if (dot) dot.style.background = color;
if (title) title.textContent = `${this.sourceModule} / ${this.feedId || label}`;
}
// ── Fetch feed data ──
async #fetchFeed() {
if (!this.sourceModule) return;
if (this.#loading) return;
this.#loading = true;
this.#updateStatus("loading");
try {
// Construct feed URL based on feed ID
const space = this.#getSpaceSlug();
const feedEndpoint = this.#getFeedEndpoint();
const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
// Normalize: extract the array from common response shapes
if (Array.isArray(data)) {
this.#feedData = data.slice(0, this.maxItems);
} else if (data.notes) {
this.#feedData = data.notes.slice(0, this.maxItems);
} else if (data.notebooks) {
this.#feedData = data.notebooks.slice(0, this.maxItems);
} else if (data.proposals) {
this.#feedData = data.proposals.slice(0, this.maxItems);
} else if (data.tasks) {
this.#feedData = data.tasks.slice(0, this.maxItems);
} else if (data.nodes) {
this.#feedData = data.nodes.slice(0, this.maxItems);
} else if (data.flows) {
this.#feedData = data.flows.slice(0, this.maxItems);
} else {
// Try to use the data as-is if it has array-like fields
const firstArray = Object.values(data).find(v => Array.isArray(v));
this.#feedData = firstArray ? (firstArray as any[]).slice(0, this.maxItems) : [data];
}
this.#error = null;
this.#renderItems();
this.#updateStatus("ok");
} catch (err: any) {
this.#error = err.message || "Failed to fetch";
this.#updateStatus("error");
} finally {
this.#loading = false;
}
}
#getFeedEndpoint(): string {
// Map feed IDs to actual API endpoints
const FEED_ENDPOINTS: Record<string, Record<string, string>> = {
notes: {
"notes-by-tag": "notes",
"recent-notes": "notes",
default: "notes",
},
funds: {
"treasury-flows": "flows",
"transactions": "flows",
default: "flows",
},
vote: {
proposals: "proposals",
decisions: "proposals?status=PASSED,FAILED",
default: "proposals",
},
choices: {
"poll-results": "choices",
default: "choices",
},
wallet: {
balances: "safe/detect",
transfers: "safe/detect",
default: "safe/detect",
},
data: {
analytics: "stats",
"active-users": "active",
default: "stats",
},
work: {
"task-activity": "spaces",
"board-summary": "spaces",
default: "spaces",
},
network: {
"trust-graph": "graph",
connections: "people",
default: "graph",
},
trips: {
"trip-expenses": "trips",
itinerary: "trips",
default: "trips",
},
};
const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule];
if (!moduleEndpoints) return this.feedId || "info";
return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId;
}
#getSpaceSlug(): string {
// Try to get from URL
const parts = window.location.pathname.split("/").filter(Boolean);
return parts[0] || "demo";
}
// ── Render feed items ──
#renderItems() {
const container = this.#inner?.querySelector(".feed-items");
if (!container) return;
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
if (this.#feedData.length === 0) {
container.innerHTML = `<div class="feed-empty">No data</div>`;
return;
}
container.innerHTML = this.#feedData.map((item, i) => {
const title = item.title || item.name || item.label || item.id || `Item ${i + 1}`;
const subtitle = item.description || item.content_plain?.slice(0, 80) || item.status || item.type || "";
const badge = item.status || item.kind || item.type || "";
const editable = this.#isEditable(item);
return `
<div class="feed-item ${editable ? "feed-item--editable" : ""}" data-index="${i}" data-item-id="${item.id || ""}">
<div class="feed-item-line" style="background:${color}"></div>
<div class="feed-item-content">
<div class="feed-item-title">${this.#escapeHtml(String(title))}</div>
${subtitle ? `<div class="feed-item-subtitle">${this.#escapeHtml(String(subtitle).slice(0, 100))}</div>` : ""}
</div>
<div class="feed-item-actions">
${editable ? `<button class="feed-item-edit" data-edit="${i}" title="Edit">✎</button>` : ""}
${badge ? `<div class="feed-item-badge" style="color:${color}">${this.#escapeHtml(String(badge))}</div>` : ""}
</div>
</div>
`;
}).join("");
// Attach item click and edit events
container.querySelectorAll<HTMLElement>(".feed-item").forEach(el => {
// Double-click to navigate to source
el.addEventListener("dblclick", (e) => {
e.stopPropagation();
const idx = parseInt(el.dataset.index || "0", 10);
const item = this.#feedData[idx];
if (item) this.#navigateToItem(item);
});
});
container.querySelectorAll<HTMLElement>(".feed-item-edit").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt(btn.dataset.edit || "0", 10);
this.#openEditOverlay(idx);
});
});
}
/** Check if an item supports write-back */
#isEditable(item: any): boolean {
// Items with an ID from modules that support PUT/PATCH are editable
if (!item.id) return false;
const editableModules = ["notes", "work", "vote", "trips"];
return editableModules.includes(this.sourceModule);
}
/** Navigate to the source item in its module */
#navigateToItem(item: any) {
const space = this.#getSpaceSlug();
const mod = this.sourceModule;
// Build a deep link to the item in its source module
// Emit an event so the canvas/shell can handle it
this.dispatchEvent(new CustomEvent("feed-navigate", {
bubbles: true,
detail: {
sourceModule: mod,
itemId: item.id,
item,
url: `/${space}/${mod}`,
}
}));
}
// ── Edit overlay (bidirectional write-back) ──
#openEditOverlay(index: number) {
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
if (!overlay) return;
const item = this.#feedData[index];
if (!item) return;
this.#editingIndex = index;
const color = FLOW_COLORS[this.flowKind] || "#94a3b8";
// Build edit fields based on item properties
const editableFields = this.#getEditableFields(item);
overlay.innerHTML = `
<div class="edit-panel">
<div class="edit-header">
<span style="color:${color}">Edit: ${this.#escapeHtml(item.title || item.name || "Item")}</span>
<button class="edit-close">&times;</button>
</div>
<div class="edit-fields">
${editableFields.map(f => `
<label class="edit-field">
<span class="edit-label">${f.label}</span>
${f.type === "textarea"
? `<textarea class="edit-input" data-field="${f.key}" rows="3">${this.#escapeHtml(String(f.value))}</textarea>`
: f.type === "select"
? `<select class="edit-input" data-field="${f.key}">
${f.options!.map(o => `<option value="${o}" ${o === f.value ? "selected" : ""}>${o}</option>`).join("")}
</select>`
: `<input class="edit-input" type="text" data-field="${f.key}" value="${this.#escapeHtml(String(f.value))}" />`
}
</label>
`).join("")}
</div>
<div class="edit-actions">
<button class="edit-cancel">Cancel</button>
<button class="edit-save" style="background:${color}20; color:${color}; border-color:${color}40">Save & Push</button>
</div>
</div>
`;
overlay.classList.add("open");
// Events
overlay.querySelector(".edit-close")?.addEventListener("click", () => this.#closeEditOverlay());
overlay.querySelector(".edit-cancel")?.addEventListener("click", () => this.#closeEditOverlay());
overlay.querySelector(".edit-save")?.addEventListener("click", () => this.#saveEdit());
}
#closeEditOverlay() {
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
if (overlay) {
overlay.classList.remove("open");
overlay.innerHTML = "";
}
this.#editingIndex = null;
}
async #saveEdit() {
if (this.#editingIndex === null) return;
const item = this.#feedData[this.#editingIndex];
if (!item?.id) return;
const overlay = this.#inner?.querySelector<HTMLElement>(".feed-edit-overlay");
if (!overlay) return;
// Collect edited values
const updates: Record<string, string> = {};
overlay.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(".edit-input").forEach(el => {
const field = el.dataset.field;
if (field && el.value !== String(item[field] ?? "")) {
updates[field] = el.value;
}
});
if (Object.keys(updates).length === 0) {
this.#closeEditOverlay();
return;
}
// Write back to source module API
try {
const space = this.#getSpaceSlug();
const endpoint = this.#getWriteBackEndpoint(item);
const url = `/${space}/${this.sourceModule}/api/${endpoint}`;
const method = this.#getWriteBackMethod();
const token = this.#getAuthToken();
const res = await fetch(url, {
method,
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(updates),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: "Failed" }));
throw new Error(err.error || `HTTP ${res.status}`);
}
// Update local data
Object.assign(item, updates);
this.#renderItems();
this.#closeEditOverlay();
// Emit event for flow tracking
this.dispatchEvent(new CustomEvent("feed-writeback", {
bubbles: true,
detail: {
sourceModule: this.sourceModule,
itemId: item.id,
updates,
flowKind: this.flowKind,
}
}));
} catch (err: any) {
// Show error in overlay
const actions = overlay.querySelector(".edit-actions");
if (actions) {
const existing = actions.querySelector(".edit-error");
if (existing) existing.remove();
const errorEl = document.createElement("div");
errorEl.className = "edit-error";
errorEl.textContent = err.message;
actions.prepend(errorEl);
}
}
}
#getEditableFields(item: any): { key: string; label: string; value: string; type: string; options?: string[] }[] {
const fields: { key: string; label: string; value: string; type: string; options?: string[] }[] = [];
// Module-specific editable fields
switch (this.sourceModule) {
case "notes":
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
if (item.content !== undefined) fields.push({ key: "content", label: "Content", value: item.content || "", type: "textarea" });
break;
case "work":
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
if (item.status !== undefined) fields.push({
key: "status", label: "Status", value: item.status,
type: "select", options: ["TODO", "IN_PROGRESS", "REVIEW", "DONE"],
});
if (item.priority !== undefined) fields.push({
key: "priority", label: "Priority", value: item.priority || "MEDIUM",
type: "select", options: ["LOW", "MEDIUM", "HIGH", "URGENT"],
});
break;
case "vote":
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
break;
case "trips":
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
break;
default:
// Generic: expose title and description if present
if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" });
if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" });
}
return fields;
}
#getWriteBackEndpoint(item: any): string {
switch (this.sourceModule) {
case "notes": return `notes/${item.id}`;
case "work": return `tasks/${item.id}`;
case "vote": return `proposals/${item.id}`;
case "trips": return `trips/${item.id}`;
default: return `${item.id}`;
}
}
#getWriteBackMethod(): string {
switch (this.sourceModule) {
case "work": return "PATCH";
default: return "PUT";
}
}
#getAuthToken(): string | null {
// Try to get token from EncryptID (stored in localStorage by rstack-identity)
try {
return localStorage.getItem("encryptid-token") || null;
} catch {
return null;
}
}
#updateStatus(state: "loading" | "ok" | "error") {
const el = this.#inner?.querySelector<HTMLElement>(".feed-status");
if (!el) return;
if (state === "loading") {
el.textContent = "Loading...";
el.style.color = "#94a3b8";
} else if (state === "error") {
el.textContent = this.#error || "Error";
el.style.color = "#ef4444";
} else {
el.textContent = `${this.#feedData.length} items`;
el.style.color = FLOW_COLORS[this.flowKind] || "#94a3b8";
}
}
// ── Auto-refresh ──
#startAutoRefresh() {
if (this.refreshInterval > 0) {
this.#refreshTimer = setInterval(() => this.#fetchFeed(), this.refreshInterval);
}
}
#stopAutoRefresh() {
if (this.#refreshTimer) {
clearInterval(this.#refreshTimer);
this.#refreshTimer = null;
}
}
// ── Serialization ──
toJSON() {
return {
...super.toJSON(),
type: "folk-feed",
sourceLayer: this.sourceLayer,
sourceModule: this.sourceModule,
feedId: this.feedId,
flowKind: this.flowKind,
feedFilter: this.feedFilter,
maxItems: this.maxItems,
refreshInterval: this.refreshInterval,
};
}
#escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
static define(tag = "folk-feed") {
if (!customElements.get(tag)) customElements.define(tag, FolkFeed);
}
}
// ── Styles ──
const FEED_STYLES = `
.folk-feed-inner {
display: flex;
flex-direction: column;
height: 100%;
border-radius: 8px;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: rgba(15, 23, 42, 0.9);
border: 1px solid rgba(255,255,255,0.08);
}
.feed-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.feed-kind-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.feed-title {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
color: #e2e8f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.feed-refresh {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: #64748b;
font-size: 0.85rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.feed-refresh:hover {
color: #e2e8f0;
background: rgba(255,255,255,0.06);
}
.feed-items {
flex: 1;
overflow-y: auto;
padding: 4px 0;
scrollbar-width: thin;
scrollbar-color: rgba(148,163,184,0.2) transparent;
}
.feed-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 12px;
transition: background 0.12s;
cursor: default;
}
.feed-item:hover {
background: rgba(255,255,255,0.03);
}
.feed-item-line {
width: 3px;
min-height: 24px;
border-radius: 2px;
flex-shrink: 0;
margin-top: 2px;
opacity: 0.6;
}
.feed-item-content {
flex: 1;
min-width: 0;
}
.feed-item-title {
font-size: 0.75rem;
font-weight: 500;
color: #e2e8f0;
line-height: 1.3;
}
.feed-item-subtitle {
font-size: 0.65rem;
color: #64748b;
margin-top: 2px;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.feed-item-badge {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
padding: 2px 4px;
border-radius: 3px;
background: rgba(255,255,255,0.04);
flex-shrink: 0;
margin-top: 2px;
}
.feed-navigate {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: #64748b;
font-size: 0.75rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.feed-navigate:hover {
color: #22d3ee;
background: rgba(34,211,238,0.1);
}
.feed-item--editable { cursor: pointer; }
.feed-item--editable:hover { background: rgba(255,255,255,0.05); }
.feed-item-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.feed-item-edit {
width: 20px;
height: 20px;
border: none;
border-radius: 3px;
background: transparent;
color: #475569;
font-size: 0.7rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s, color 0.15s, background 0.15s;
}
.feed-item:hover .feed-item-edit { opacity: 0.7; }
.feed-item-edit:hover { opacity: 1 !important; color: #22d3ee; background: rgba(34,211,238,0.1); }
.feed-empty {
padding: 20px;
text-align: center;
font-size: 0.75rem;
color: #475569;
}
.feed-status {
padding: 4px 12px;
font-size: 0.6rem;
text-align: right;
border-top: 1px solid rgba(255,255,255,0.04);
flex-shrink: 0;
}
/* ── Edit overlay ── */
.feed-edit-overlay {
display: none;
position: absolute;
inset: 0;
background: rgba(15, 23, 42, 0.95);
border-radius: 8px;
z-index: 10;
overflow: auto;
}
.feed-edit-overlay.open { display: flex; }
.edit-panel {
display: flex;
flex-direction: column;
width: 100%;
padding: 12px;
gap: 10px;
}
.edit-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
font-weight: 600;
}
.edit-close {
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: #94a3b8;
font-size: 1rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.edit-close:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.edit-fields {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.edit-field {
display: flex;
flex-direction: column;
gap: 3px;
}
.edit-label {
font-size: 0.6rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.edit-input {
padding: 6px 8px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 5px;
background: rgba(255,255,255,0.04);
color: #e2e8f0;
font-size: 0.75rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
resize: vertical;
}
.edit-input:focus { border-color: rgba(34,211,238,0.4); }
select.edit-input { cursor: pointer; }
.edit-actions {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
}
.edit-cancel, .edit-save {
padding: 5px 12px;
border-radius: 5px;
font-size: 0.7rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.edit-cancel {
border: 1px solid rgba(255,255,255,0.1);
background: transparent;
color: #94a3b8;
}
.edit-cancel:hover { color: #e2e8f0; }
.edit-save {
border: 1px solid;
}
.edit-save:hover { opacity: 0.8; }
.edit-error {
font-size: 0.65rem;
color: #ef4444;
flex: 1;
}
`;

608
lib/folk-rapp.ts Normal file
View File

@ -0,0 +1,608 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { rspaceNavUrl } from "../shared/url-helpers";
/**
* <folk-rapp> Embeds a live rApp module as a shape on the canvas.
*
* Unlike folk-embed (generic URL iframe), folk-rapp understands the module
* system: it stores moduleId + spaceSlug, derives the iframe URL, shows
* the module's icon/badge in the header, and can switch modules in-place.
*
* PostMessage protocol:
* Parent iframe: { source: "rspace-parent", type: "context", shapeId, space, moduleId }
* iframe parent: { source: "rspace-canvas", type: "shape-updated", ... } (from CommunitySync)
* iframe parent: { source: "rspace-rapp", type: "navigate", moduleId }
*/
// Module metadata for header display (subset of rstack-app-switcher badges)
const MODULE_META: Record<string, { badge: string; color: string; name: string; icon: string }> = {
rnotes: { badge: "rN", color: "#fcd34d", name: "rNotes", icon: "📝" },
rphotos: { badge: "rPh", color: "#f9a8d4", name: "rPhotos", icon: "📸" },
rbooks: { badge: "rB", color: "#fda4af", name: "rBooks", icon: "📚" },
rpubs: { badge: "rP", color: "#fda4af", name: "rPubs", icon: "📖" },
rfiles: { badge: "rFi", color: "#67e8f9", name: "rFiles", icon: "📁" },
rwork: { badge: "rWo", color: "#cbd5e1", name: "rWork", icon: "📋" },
rforum: { badge: "rFo", color: "#fcd34d", name: "rForum", icon: "💬" },
rinbox: { badge: "rI", color: "#a5b4fc", name: "rInbox", icon: "📧" },
rtube: { badge: "rTu", color: "#f9a8d4", name: "rTube", icon: "🎬" },
rfunds: { badge: "rF", color: "#bef264", name: "rFunds", icon: "🌊" },
rwallet: { badge: "rW", color: "#fde047", name: "rWallet", icon: "💰" },
rvote: { badge: "rV", color: "#c4b5fd", name: "rVote", icon: "🗳️" },
rcart: { badge: "rCt", color: "#fdba74", name: "rCart", icon: "🛒" },
rdata: { badge: "rD", color: "#d8b4fe", name: "rData", icon: "📊" },
rnetwork: { badge: "rNe", color: "#93c5fd", name: "rNetwork", icon: "🌍" },
rsplat: { badge: "r3", color: "#d8b4fe", name: "rSplat", icon: "🔮" },
rswag: { badge: "rSw", color: "#fda4af", name: "rSwag", icon: "🎨" },
rchoices: { badge: "rCo", color: "#f0abfc", name: "rChoices", icon: "🤔" },
rcal: { badge: "rC", color: "#7dd3fc", name: "rCal", icon: "📅" },
rtrips: { badge: "rT", color: "#6ee7b7", name: "rTrips", icon: "✈️" },
rmaps: { badge: "rM", color: "#86efac", name: "rMaps", icon: "🗺️" },
};
const styles = css`
:host {
background: #1e293b;
border-radius: 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
min-width: 320px;
min-height: 240px;
overflow: hidden;
}
.rapp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: var(--rapp-color, #334155);
color: #0f172a;
font-size: 12px;
font-weight: 700;
cursor: move;
user-select: none;
border-radius: 10px 10px 0 0;
}
.rapp-header-left {
display: flex;
align-items: center;
gap: 7px;
position: relative;
}
.rapp-badge {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.15);
font-size: 0.55rem;
font-weight: 900;
line-height: 1;
flex-shrink: 0;
}
.rapp-name {
font-size: 12px;
font-weight: 700;
}
.rapp-icon {
font-size: 13px;
}
.rapp-actions {
display: flex;
gap: 2px;
}
.rapp-actions button {
background: transparent;
border: none;
color: #0f172a;
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
font-size: 12px;
opacity: 0.7;
transition: opacity 0.15s, background 0.15s;
}
.rapp-actions button:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.1);
}
.rapp-content {
width: 100%;
height: calc(100% - 34px);
position: relative;
background: #0f172a;
}
.rapp-iframe {
width: 100%;
height: 100%;
border: none;
border-radius: 0 0 10px 10px;
}
.rapp-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #64748b;
font-size: 13px;
}
.rapp-loading .spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(100, 116, 139, 0.3);
border-top-color: #64748b;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.rapp-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 8px;
color: #ef4444;
font-size: 13px;
padding: 16px;
text-align: center;
}
/* Module picker (shown when no moduleId set) */
.rapp-picker {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
height: 100%;
overflow-y: auto;
}
.rapp-picker-title {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 4px;
}
.rapp-picker-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.12s;
color: #e2e8f0;
font-size: 13px;
border: none;
background: transparent;
text-align: left;
width: 100%;
}
.rapp-picker-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.rapp-picker-badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 5px;
font-size: 0.5rem;
font-weight: 900;
color: #0f172a;
flex-shrink: 0;
}
/* Module switcher dropdown */
.rapp-switcher {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
min-width: 180px;
max-height: 300px;
overflow-y: auto;
background: #1e293b;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
padding: 4px;
z-index: 100;
display: none;
}
.rapp-switcher.open {
display: block;
}
.rapp-switcher-item {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 8px;
border-radius: 5px;
cursor: pointer;
color: #e2e8f0;
font-size: 12px;
border: none;
background: transparent;
width: 100%;
text-align: left;
transition: background 0.12s;
}
.rapp-switcher-item:hover {
background: rgba(255, 255, 255, 0.08);
}
.rapp-switcher-item.active {
background: rgba(6, 182, 212, 0.15);
}
.rapp-switcher-badge {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 4px;
font-size: 0.45rem;
font-weight: 900;
color: #0f172a;
flex-shrink: 0;
}
/* Status indicator for postMessage connection */
.rapp-status {
width: 6px;
height: 6px;
border-radius: 50%;
background: #475569;
flex-shrink: 0;
transition: background 0.3s;
}
.rapp-status.connected {
background: #22c55e;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-rapp": FolkRApp;
}
}
export class FolkRApp extends FolkShape {
static override tagName = "folk-rapp";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#moduleId: string = "";
#spaceSlug: string = "";
#iframe: HTMLIFrameElement | null = null;
#contentEl: HTMLElement | null = null;
#messageHandler: ((e: MessageEvent) => void) | null = null;
#statusEl: HTMLElement | null = null;
get moduleId() { return this.#moduleId; }
set moduleId(value: string) {
if (this.#moduleId === value) return;
this.#moduleId = value;
this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
}
get spaceSlug() { return this.#spaceSlug; }
set spaceSlug(value: string) {
if (this.#spaceSlug === value) return;
this.#spaceSlug = value;
this.requestUpdate("spaceSlug");
this.dispatchEvent(new CustomEvent("content-change"));
this.#loadModule();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#moduleId = this.getAttribute("module-id") || "";
this.#spaceSlug = this.getAttribute("space-slug") || "";
const meta = MODULE_META[this.#moduleId];
const headerColor = meta?.color || "#475569";
const headerName = meta?.name || this.#moduleId || "rApp";
const headerBadge = meta?.badge || "r?";
const headerIcon = meta?.icon || "📱";
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="rapp-header" style="--rapp-color: ${headerColor}">
<div class="rapp-header-left">
<span class="rapp-badge">${headerBadge}</span>
<span class="rapp-name">${headerName}</span>
<span class="rapp-icon">${headerIcon}</span>
<span class="rapp-status" title="Not connected"></span>
<div class="rapp-switcher"></div>
</div>
<div class="rapp-actions">
<button class="switch-btn" title="Switch module"></button>
<button class="open-tab-btn" title="Open in tab"></button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="rapp-content"></div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
this.#contentEl = wrapper.querySelector(".rapp-content") as HTMLElement;
this.#statusEl = wrapper.querySelector(".rapp-status") as HTMLElement;
const switchBtn = wrapper.querySelector(".switch-btn") as HTMLButtonElement;
const openTabBtn = wrapper.querySelector(".open-tab-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
const switcherEl = wrapper.querySelector(".rapp-switcher") as HTMLElement;
// Module switcher dropdown
this.#buildSwitcher(switcherEl);
switchBtn.addEventListener("click", (e) => {
e.stopPropagation();
switcherEl.classList.toggle("open");
});
// Close switcher when clicking elsewhere
const closeSwitcher = () => switcherEl.classList.remove("open");
root.addEventListener("click", closeSwitcher);
// Open in tab — navigate to the module's page via tab bar
openTabBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#moduleId && this.#spaceSlug) {
window.location.href = rspaceNavUrl(this.#spaceSlug, this.#moduleId);
}
});
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Set up postMessage listener
this.#messageHandler = (e: MessageEvent) => this.#handleMessage(e);
window.addEventListener("message", this.#messageHandler);
// Load content
if (this.#moduleId) {
this.#loadModule();
} else {
this.#showPicker();
}
return root;
}
disconnectedCallback() {
super.disconnectedCallback?.();
if (this.#messageHandler) {
window.removeEventListener("message", this.#messageHandler);
this.#messageHandler = null;
}
}
#buildSwitcher(switcherEl: HTMLElement) {
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name} ${meta.icon}</span>
</button>
`)
.join("");
switcherEl.innerHTML = items;
switcherEl.querySelectorAll(".rapp-switcher-item").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const modId = (btn as HTMLElement).dataset.module;
if (modId && modId !== this.#moduleId) {
this.moduleId = modId;
this.#buildSwitcher(switcherEl);
}
switcherEl.classList.remove("open");
});
});
}
/** Handle postMessage from embedded iframe */
#handleMessage(e: MessageEvent) {
if (!this.#iframe) return;
// Only accept messages from our iframe
if (e.source !== this.#iframe.contentWindow) return;
const msg = e.data;
if (!msg || typeof msg !== "object") return;
// CommunitySync shape updates from the embedded module
if (msg.source === "rspace-canvas" && msg.type === "shape-updated") {
this.dispatchEvent(new CustomEvent("rapp-data", {
detail: { moduleId: this.#moduleId, shapeId: msg.shapeId, data: msg.data },
bubbles: true,
}));
// Mark as connected
if (this.#statusEl) {
this.#statusEl.classList.add("connected");
this.#statusEl.title = "Connected — receiving data";
}
}
// Navigation request from embedded module
if (msg.source === "rspace-rapp" && msg.type === "navigate" && msg.moduleId) {
this.moduleId = msg.moduleId;
}
}
/** Send context to the iframe after it loads */
#sendContext() {
if (!this.#iframe?.contentWindow) return;
try {
this.#iframe.contentWindow.postMessage({
source: "rspace-parent",
type: "context",
shapeId: this.id,
space: this.#spaceSlug,
moduleId: this.#moduleId,
embedded: true,
}, "*");
} catch {
// cross-origin or iframe not ready
}
}
#loadModule() {
if (!this.#contentEl || !this.#moduleId) return;
// Update header
const meta = MODULE_META[this.#moduleId];
const header = this.shadowRoot?.querySelector(".rapp-header") as HTMLElement;
if (header && meta) {
header.style.setProperty("--rapp-color", meta.color);
const badge = header.querySelector(".rapp-badge");
const name = header.querySelector(".rapp-name");
const icon = header.querySelector(".rapp-icon");
if (badge) badge.textContent = meta.badge;
if (name) name.textContent = meta.name;
if (icon) icon.textContent = meta.icon;
}
// Reset connection status
if (this.#statusEl) {
this.#statusEl.classList.remove("connected");
this.#statusEl.title = "Loading...";
}
// Show loading state
this.#contentEl.innerHTML = `
<div class="rapp-loading">
<div class="spinner"></div>
<span>Loading ${meta?.name || this.#moduleId}...</span>
</div>
`;
// Create iframe
const space = this.#spaceSlug || "demo";
const iframeUrl = `/${space}/${this.#moduleId}`;
const iframe = document.createElement("iframe");
iframe.className = "rapp-iframe";
iframe.src = iframeUrl;
iframe.loading = "lazy";
iframe.allow = "clipboard-write";
iframe.addEventListener("load", () => {
// Remove loading indicator
const loading = this.#contentEl?.querySelector(".rapp-loading");
if (loading) loading.remove();
// Send context to the newly loaded iframe
this.#sendContext();
});
iframe.addEventListener("error", () => {
if (this.#contentEl) {
this.#contentEl.innerHTML = `
<div class="rapp-error">
<span>Failed to load ${meta?.name || this.#moduleId}</span>
<button class="rapp-picker-item" style="justify-content: center; color: #94a3b8;">
Retry
</button>
</div>
`;
this.#contentEl.querySelector("button")?.addEventListener("click", () => this.#loadModule());
}
});
this.#contentEl.appendChild(iframe);
this.#iframe = iframe;
}
#showPicker() {
if (!this.#contentEl) return;
const items = Object.entries(MODULE_META)
.map(([id, meta]) => `
<button class="rapp-picker-item" data-module="${id}">
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>
<span>${meta.name}</span>
<span>${meta.icon}</span>
</button>
`)
.join("");
this.#contentEl.innerHTML = `
<div class="rapp-picker">
<span class="rapp-picker-title">Choose an rApp to embed</span>
${items}
</div>
`;
this.#contentEl.querySelectorAll(".rapp-picker-item").forEach((btn) => {
btn.addEventListener("click", () => {
const modId = (btn as HTMLElement).dataset.module;
if (modId) {
this.moduleId = modId;
}
});
});
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-rapp",
moduleId: this.#moduleId,
spaceSlug: this.#spaceSlug,
};
}
}

View File

@ -63,6 +63,15 @@ export * from "./folk-choice-vote";
export * from "./folk-choice-rank";
export * from "./folk-choice-spider";
// Nested Space Shape
export * from "./folk-canvas";
// rApp Embed Shape (cross-app embedding)
export * from "./folk-rapp";
// Feed Shape (inter-layer data flow)
export * from "./folk-feed";
// Sync
export * from "./community-sync";
export * from "./presence";

100
lib/layer-types.ts Normal file
View File

@ -0,0 +1,100 @@
/**
* Layer & Flow types for the rSpace tab/layer system.
*
* Each "tab" is a Layer a named canvas page backed by a module.
* Layers stack vertically. Flows are typed connections (economic, trust,
* data, attention, governance) that move between shapes on different layers.
*
* The "stack view" renders all layers from the side, showing flows as
* arcs/lines between strata.
*/
// ── Flow types ──
export type FlowKind =
| "economic" // token/currency/value flows
| "trust" // reputation, attestation, endorsement
| "data" // information, content, feeds
| "attention" // views, engagement, focus
| "governance" // votes, proposals, decisions
| "resource" // files, assets, media
| "custom"; // user-defined
export const FLOW_COLORS: Record<FlowKind, string> = {
economic: "#bef264", // lime
trust: "#c4b5fd", // violet
data: "#67e8f9", // cyan
attention: "#fcd34d", // amber
governance: "#f0abfc", // fuchsia
resource: "#6ee7b7", // emerald
custom: "#94a3b8", // slate
};
export const FLOW_LABELS: Record<FlowKind, string> = {
economic: "Economic",
trust: "Trust",
data: "Data",
attention: "Attention",
governance: "Governance",
resource: "Resource",
custom: "Custom",
};
// ── Layer definition ──
export interface Layer {
/** Unique layer ID (e.g. "layer-abc123") */
id: string;
/** Module ID this layer is bound to (e.g. "canvas", "notes", "funds") */
moduleId: string;
/** Display label (defaults to module name, user-customizable) */
label: string;
/** Position in the tab bar (0-indexed, left to right) */
order: number;
/** Layer color for the stack view strata */
color: string;
/** Whether this layer is visible in stack view */
visible: boolean;
/** Created timestamp */
createdAt: number;
}
// ── Inter-layer flow ──
export interface LayerFlow {
/** Unique flow ID */
id: string;
/** Flow type */
kind: FlowKind;
/** Source layer ID */
sourceLayerId: string;
/** Source shape ID (optional — can be layer-wide) */
sourceShapeId?: string;
/** Target layer ID */
targetLayerId: string;
/** Target shape ID (optional — can be layer-wide) */
targetShapeId?: string;
/** Human-readable label */
label?: string;
/** Flow strength/weight (0-1, affects visual thickness) */
strength: number;
/** Whether this flow is currently active */
active: boolean;
/** Custom color override */
color?: string;
/** Metadata for the flow */
meta?: Record<string, unknown>;
}
// ── Layer config stored in Automerge doc ──
export interface LayerConfig {
/** Ordered list of layers */
layers: { [id: string]: Layer };
/** Inter-layer flows */
flows: { [id: string]: LayerFlow };
/** Currently active layer ID */
activeLayerId: string;
/** View mode: 'flat' (normal tabs) or 'stack' (side/3D view) */
viewMode: "flat" | "stack";
}

View File

@ -0,0 +1,12 @@
/* Books module — additional styles for shell-wrapped pages */
/* Dark theme for reader page */
body[data-theme="dark"] {
background: #0f172a;
}
/* Library grid page */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 56px);
}

View File

@ -0,0 +1,520 @@
/**
* <folk-book-reader> Flipbook PDF reader using pdf.js + StPageFlip.
*
* Renders each PDF page to canvas, converts to images, then displays
* in a realistic page-flip animation. Caches rendered pages in IndexedDB.
* Saves reading position to localStorage.
*
* Attributes:
* pdf-url URL to the PDF file
* book-id Unique ID for caching/position tracking
* title Book title (for display)
* author Book author (for display)
*/
// pdf.js is loaded from CDN; StPageFlip is imported from npm
// (we'll load both dynamically to avoid bundling issues)
const PDFJS_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.min.mjs";
const PDFJS_WORKER_CDN = "https://unpkg.com/pdfjs-dist@4.9.155/build/pdf.worker.min.mjs";
const STPAGEFLIP_CDN = "https://unpkg.com/page-flip@2.0.7/dist/js/page-flip.browser.js";
interface CachedBook {
images: string[];
numPages: number;
aspectRatio: number;
}
export class FolkBookReader extends HTMLElement {
private _pdfUrl = "";
private _bookId = "";
private _title = "";
private _author = "";
private _pageImages: string[] = [];
private _numPages = 0;
private _currentPage = 0;
private _aspectRatio = 1.414; // A4 default
private _isLoading = true;
private _loadingProgress = 0;
private _loadingStatus = "Preparing...";
private _error: string | null = null;
private _flipBook: any = null;
private _db: IDBDatabase | null = null;
static get observedAttributes() {
return ["pdf-url", "book-id", "title", "author"];
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "pdf-url") this._pdfUrl = val;
else if (name === "book-id") this._bookId = val;
else if (name === "title") this._title = val;
else if (name === "author") this._author = val;
}
async connectedCallback() {
this._pdfUrl = this.getAttribute("pdf-url") || "";
this._bookId = this.getAttribute("book-id") || "";
this._title = this.getAttribute("title") || "";
this._author = this.getAttribute("author") || "";
this.attachShadow({ mode: "open" });
this.renderLoading();
// Restore reading position
const savedPage = localStorage.getItem(`book-position-${this._bookId}`);
if (savedPage) this._currentPage = parseInt(savedPage) || 0;
try {
await this.openDB();
const cached = await this.loadFromCache();
if (cached) {
this._pageImages = cached.images;
this._numPages = cached.numPages;
this._aspectRatio = cached.aspectRatio;
this._isLoading = false;
this.renderReader();
} else {
await this.loadAndRenderPDF();
}
} catch (e: any) {
this._error = e.message || "Failed to load book";
this._isLoading = false;
this.renderError();
}
}
disconnectedCallback() {
// Save position
localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
this._flipBook?.destroy();
this._db?.close();
}
// ── IndexedDB cache ──
private openDB(): Promise<void> {
return new Promise((resolve, reject) => {
const req = indexedDB.open("rspace-books-cache", 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains("book-images")) {
db.createObjectStore("book-images");
}
};
req.onsuccess = () => { this._db = req.result; resolve(); };
req.onerror = () => reject(req.error);
});
}
private loadFromCache(): Promise<CachedBook | null> {
return new Promise((resolve) => {
if (!this._db) { resolve(null); return; }
const tx = this._db.transaction("book-images", "readonly");
const store = tx.objectStore("book-images");
const req = store.get(this._bookId);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
}
private saveToCache(data: CachedBook): Promise<void> {
return new Promise((resolve) => {
if (!this._db) { resolve(); return; }
const tx = this._db.transaction("book-images", "readwrite");
const store = tx.objectStore("book-images");
store.put(data, this._bookId);
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}
// ── PDF rendering ──
private async loadAndRenderPDF() {
this._loadingStatus = "Loading PDF.js...";
this.updateLoadingUI();
// Load pdf.js
const pdfjsLib = await import(/* @vite-ignore */ PDFJS_CDN);
pdfjsLib.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_CDN;
this._loadingStatus = "Downloading PDF...";
this.updateLoadingUI();
const pdf = await pdfjsLib.getDocument(this._pdfUrl).promise;
this._numPages = pdf.numPages;
this._pageImages = [];
// Get aspect ratio from first page
const firstPage = await pdf.getPage(1);
const viewport = firstPage.getViewport({ scale: 1 });
this._aspectRatio = viewport.width / viewport.height;
const scale = 2; // 2x for quality
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d")!;
for (let i = 1; i <= pdf.numPages; i++) {
this._loadingStatus = `Rendering page ${i} of ${pdf.numPages}...`;
this._loadingProgress = Math.round((i / pdf.numPages) * 100);
this.updateLoadingUI();
const page = await pdf.getPage(i);
const vp = page.getViewport({ scale });
canvas.width = vp.width;
canvas.height = vp.height;
ctx.clearRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: ctx, viewport: vp }).promise;
this._pageImages.push(canvas.toDataURL("image/jpeg", 0.85));
}
// Cache
await this.saveToCache({
images: this._pageImages,
numPages: this._numPages,
aspectRatio: this._aspectRatio,
});
this._isLoading = false;
this.renderReader();
}
// ── UI rendering ──
private renderLoading() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="loading">
<div class="loading-spinner"></div>
<div class="loading-status">${this._loadingStatus}</div>
<div class="loading-bar">
<div class="loading-fill" style="width:${this._loadingProgress}%"></div>
</div>
</div>
`;
}
private updateLoadingUI() {
if (!this.shadowRoot) return;
const status = this.shadowRoot.querySelector(".loading-status");
const fill = this.shadowRoot.querySelector(".loading-fill") as HTMLElement;
if (status) status.textContent = this._loadingStatus;
if (fill) fill.style.width = `${this._loadingProgress}%`;
}
private renderError() {
if (!this.shadowRoot) return;
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="error">
<h3>Failed to load book</h3>
<p>${this.escapeHtml(this._error || "Unknown error")}</p>
<button onclick="location.reload()">Retry</button>
</div>
`;
}
private renderReader() {
if (!this.shadowRoot) return;
// Calculate dimensions
const maxW = Math.min(window.innerWidth * 0.9, 800);
const maxH = window.innerHeight - 160;
let pageW = maxW / 2;
let pageH = pageW / this._aspectRatio;
if (pageH > maxH) {
pageH = maxH;
pageW = pageH * this._aspectRatio;
}
this.shadowRoot.innerHTML = `
${this.getStyles()}
<div class="reader-container">
<div class="rapp-nav">
<a class="rapp-nav__back" href="/${window.location.pathname.split('/')[1]}/rbooks">\u2190 Library</a>
<span class="rapp-nav__title">${this.escapeHtml(this._title)}</span>
${this._author ? `<span class="rapp-nav__subtitle">by ${this.escapeHtml(this._author)}</span>` : ""}
<span class="rapp-nav__meta">
Page <span class="current-page">${this._currentPage + 1}</span> of ${this._numPages}
</span>
</div>
<div class="flipbook-wrapper">
<button class="nav-btn nav-prev" title="Previous page"></button>
<div class="flipbook-container" style="width:${pageW * 2}px; height:${pageH}px;"></div>
<button class="nav-btn nav-next" title="Next page"></button>
</div>
<div class="reader-footer">
<button class="nav-text-btn" data-action="prev"> Previous</button>
<button class="nav-text-btn" data-action="next">Next </button>
</div>
</div>
`;
this.initFlipbook(pageW, pageH);
this.bindReaderEvents();
}
private async initFlipbook(pageW: number, pageH: number) {
if (!this.shadowRoot) return;
const container = this.shadowRoot.querySelector(".flipbook-container") as HTMLElement;
if (!container) return;
// Load StPageFlip
await this.loadStPageFlip();
const PageFlip = (window as any).St?.PageFlip;
if (!PageFlip) {
console.error("StPageFlip not loaded");
return;
}
this._flipBook = new PageFlip(container, {
width: Math.round(pageW),
height: Math.round(pageH),
showCover: true,
maxShadowOpacity: 0.5,
mobileScrollSupport: false,
useMouseEvents: true,
swipeDistance: 30,
clickEventForward: false,
flippingTime: 600,
startPage: this._currentPage,
});
// Create page elements
const pages: HTMLElement[] = [];
for (let i = 0; i < this._pageImages.length; i++) {
const page = document.createElement("div");
page.className = "page-content";
page.style.cssText = `
width: 100%;
height: 100%;
background-image: url(${this._pageImages[i]});
background-size: cover;
background-position: center;
`;
pages.push(page);
}
this._flipBook.loadFromHTML(pages);
this._flipBook.on("flip", (e: any) => {
this._currentPage = e.data;
this.updatePageCounter();
localStorage.setItem(`book-position-${this._bookId}`, String(this._currentPage));
});
}
private loadStPageFlip(): Promise<void> {
return new Promise((resolve, reject) => {
if ((window as any).St?.PageFlip) { resolve(); return; }
const script = document.createElement("script");
script.src = STPAGEFLIP_CDN;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load StPageFlip"));
document.head.appendChild(script);
});
}
private bindReaderEvents() {
if (!this.shadowRoot) return;
// Nav buttons
this.shadowRoot.querySelector(".nav-prev")?.addEventListener("click", () => {
this._flipBook?.flipPrev();
});
this.shadowRoot.querySelector(".nav-next")?.addEventListener("click", () => {
this._flipBook?.flipNext();
});
this.shadowRoot.querySelectorAll(".nav-text-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const action = (btn as HTMLElement).dataset.action;
if (action === "prev") this._flipBook?.flipPrev();
else if (action === "next") this._flipBook?.flipNext();
});
});
// Keyboard nav
document.addEventListener("keydown", (e) => {
if (e.key === "ArrowLeft") this._flipBook?.flipPrev();
else if (e.key === "ArrowRight") this._flipBook?.flipNext();
});
// Resize handler
let resizeTimer: number;
window.addEventListener("resize", () => {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => this.renderReader(), 250);
});
}
private updatePageCounter() {
if (!this.shadowRoot) return;
const el = this.shadowRoot.querySelector(".current-page");
if (el) el.textContent = String(this._currentPage + 1);
}
private getStyles(): string {
return `<style>
:host {
display: block;
width: 100%;
height: 100%;
min-height: calc(100vh - 52px);
background: #0f172a;
color: #f1f5f9;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 52px);
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #334155;
border-top-color: #60a5fa;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-status {
color: #94a3b8;
font-size: 0.9rem;
}
.loading-bar {
width: 200px;
height: 4px;
background: #1e293b;
border-radius: 2px;
overflow: hidden;
}
.loading-fill {
height: 100%;
background: #60a5fa;
transition: width 0.3s;
border-radius: 2px;
}
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100vh - 52px);
gap: 0.5rem;
}
.error h3 { color: #f87171; margin: 0; }
.error p { color: #94a3b8; margin: 0; }
.error button {
margin-top: 1rem;
padding: 0.5rem 1.5rem;
border: 1px solid #334155;
border-radius: 0.5rem;
background: #1e293b;
color: #f1f5f9;
cursor: pointer;
}
.reader-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
gap: 0.75rem;
}
.rapp-nav {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
max-width: 900px;
min-height: 36px;
}
.rapp-nav__back {
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
text-decoration: none; transition: color 0.15s, border-color 0.15s;
}
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title {
font-size: 15px; font-weight: 600; color: #e2e8f0;
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rapp-nav__subtitle {
font-size: 0.8rem;
color: #94a3b8;
margin-left: 4px;
}
.rapp-nav__meta {
font-size: 0.85rem;
color: #94a3b8;
white-space: nowrap;
}
.flipbook-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
}
.flipbook-container {
overflow: hidden;
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.nav-btn {
width: 44px;
height: 80px;
border: 1px solid #334155;
border-radius: 0.5rem;
background: #1e293b;
color: #f1f5f9;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.nav-btn:hover { background: #334155; }
.reader-footer {
display: flex;
gap: 1rem;
}
.nav-text-btn {
padding: 0.375rem 1rem;
border: 1px solid #334155;
border-radius: 0.375rem;
background: transparent;
color: #94a3b8;
font-size: 0.8rem;
cursor: pointer;
}
.nav-text-btn:hover { border-color: #60a5fa; color: #f1f5f9; }
</style>`;
}
private escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-reader", FolkBookReader);

View File

@ -0,0 +1,588 @@
/**
* <folk-book-shelf> Book grid with search, tags, and upload.
*
* Displays community books in a responsive grid. Clicking a book
* navigates to the flipbook reader. Authenticated users can upload.
*/
interface BookData {
id: string;
slug: string;
title: string;
author: string | null;
description: string | null;
pdf_size_bytes: number;
page_count: number;
tags: string[];
cover_color: string;
contributor_name: string | null;
featured: boolean;
view_count: number;
created_at: string;
}
export class FolkBookShelf extends HTMLElement {
private _books: BookData[] = [];
private _filtered: BookData[] = [];
private _spaceSlug = "personal";
private _searchTerm = "";
private _selectedTag: string | null = null;
static get observedAttributes() {
return ["space-slug"];
}
set books(val: BookData[]) {
this._books = val;
this._filtered = val;
this.render();
}
get books() {
return this._books;
}
set spaceSlug(val: string) {
this._spaceSlug = val;
}
connectedCallback() {
this.attachShadow({ mode: "open" });
this.render();
}
attributeChangedCallback(name: string, _old: string, val: string) {
if (name === "space-slug") this._spaceSlug = val;
}
private get allTags(): string[] {
const tags = new Set<string>();
for (const b of this._books) {
for (const t of b.tags || []) tags.add(t);
}
return Array.from(tags).sort();
}
private applyFilters() {
let result = this._books;
if (this._searchTerm) {
const term = this._searchTerm.toLowerCase();
result = result.filter(
(b) =>
b.title.toLowerCase().includes(term) ||
(b.author && b.author.toLowerCase().includes(term)) ||
(b.description && b.description.toLowerCase().includes(term))
);
}
if (this._selectedTag) {
const tag = this._selectedTag;
result = result.filter((b) => b.tags?.includes(tag));
}
this._filtered = result;
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
}
private render() {
if (!this.shadowRoot) return;
const tags = this.allTags;
const books = this._filtered;
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; color: #e2e8f0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rapp-nav__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #6366f1; }
.controls {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
}
.search-input {
flex: 1;
min-width: 200px;
padding: 0.5rem 0.75rem;
border: 1px solid #334155;
border-radius: 0.5rem;
background: #1e293b;
color: #f1f5f9;
font-size: 0.9rem;
}
.search-input::placeholder { color: #64748b; }
.search-input:focus { outline: none; border-color: #60a5fa; }
.tags {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.tag {
padding: 0.25rem 0.625rem;
border-radius: 999px;
border: 1px solid #334155;
background: #1e293b;
color: #94a3b8;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.15s;
}
.tag:hover { border-color: #60a5fa; color: #e2e8f0; }
.tag.active { background: #1e3a5f; border-color: #60a5fa; color: #60a5fa; }
.upload-btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
font-size: 0.875rem;
cursor: pointer;
font-weight: 500;
transition: background 0.15s;
}
.upload-btn:hover { background: #1d4ed8; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.25rem;
}
.book-card {
display: flex;
flex-direction: column;
border-radius: 0.75rem;
overflow: hidden;
background: #1e293b;
border: 1px solid #334155;
cursor: pointer;
transition: transform 0.15s, border-color 0.15s;
text-decoration: none;
color: inherit;
}
.book-card:hover {
transform: translateY(-2px);
border-color: #60a5fa;
}
.book-cover {
aspect-ratio: 3/4;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
position: relative;
}
.book-cover-title {
font-size: 0.875rem;
font-weight: 600;
color: #fff;
text-align: center;
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0,0,0,0.4);
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.featured-badge {
position: absolute;
top: 0.5rem;
right: 0.5rem;
font-size: 0.7rem;
background: rgba(250, 204, 21, 0.9);
color: #1e293b;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-weight: 600;
}
.book-info {
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.book-title {
font-size: 0.8rem;
font-weight: 600;
color: #e2e8f0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-author {
font-size: 0.75rem;
color: #94a3b8;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.book-meta {
display: flex;
justify-content: space-between;
font-size: 0.7rem;
color: #64748b;
margin-top: auto;
padding-top: 0.375rem;
}
.empty {
text-align: center;
padding: 3rem;
color: #64748b;
}
.empty h3 { margin: 0 0 0.5rem; color: #94a3b8; }
/* Upload modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay[hidden] { display: none; }
.modal {
background: #1e293b;
border: 1px solid #334155;
border-radius: 1rem;
padding: 1.5rem;
width: 90%;
max-width: 480px;
max-height: 90vh;
overflow-y: auto;
}
.modal h3 {
margin: 0 0 1rem;
color: #f1f5f9;
font-size: 1.1rem;
}
.modal label {
display: block;
margin-bottom: 0.25rem;
font-size: 0.8rem;
color: #94a3b8;
}
.modal input,
.modal textarea {
width: 100%;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid #334155;
border-radius: 0.5rem;
background: #0f172a;
color: #f1f5f9;
font-size: 0.875rem;
box-sizing: border-box;
}
.modal textarea { min-height: 80px; resize: vertical; }
.modal input:focus, .modal textarea:focus { outline: none; border-color: #60a5fa; }
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1rem;
}
.btn-cancel {
padding: 0.5rem 1rem;
border: 1px solid #334155;
border-radius: 0.5rem;
background: transparent;
color: #94a3b8;
cursor: pointer;
}
.btn-cancel:hover { border-color: #64748b; }
.btn-submit {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
background: #2563eb;
color: #fff;
cursor: pointer;
font-weight: 500;
}
.btn-submit:hover { background: #1d4ed8; }
.btn-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.drop-zone {
border: 2px dashed #334155;
border-radius: 0.75rem;
padding: 1.5rem;
text-align: center;
color: #64748b;
margin-bottom: 0.75rem;
cursor: pointer;
transition: border-color 0.15s;
}
.drop-zone:hover, .drop-zone.dragover { border-color: #60a5fa; color: #94a3b8; }
.drop-zone .selected { color: #60a5fa; font-weight: 500; }
.error-msg {
color: #f87171;
font-size: 0.8rem;
margin-bottom: 0.5rem;
}
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Library</span>
<div class="rapp-nav__actions">
<button class="rapp-nav__btn upload-btn">+ Add Book</button>
</div>
</div>
<div class="controls">
<input class="search-input" type="text" placeholder="Search books..." />
</div>
${tags.length > 0 ? `
<div class="tags">
${tags.map((t) => `<span class="tag" data-tag="${t}">${t}</span>`).join("")}
</div>
` : ""}
${books.length === 0
? `<div class="empty">
<h3>No books yet</h3>
<p>Upload a PDF to share with the community</p>
</div>`
: `<div class="grid">
${books.map((b) => `
<a class="book-card" href="/${this._spaceSlug}/rbooks/read/${b.slug}">
<div class="book-cover" style="background:${b.cover_color}">
<span class="book-cover-title">${this.escapeHtml(b.title)}</span>
${b.featured ? '<span class="featured-badge">Featured</span>' : ""}
</div>
<div class="book-info">
<div class="book-title">${this.escapeHtml(b.title)}</div>
${b.author ? `<div class="book-author">${this.escapeHtml(b.author)}</div>` : ""}
<div class="book-meta">
<span>${this.formatSize(b.pdf_size_bytes)}</span>
<span>${b.view_count} views</span>
</div>
</div>
</a>
`).join("")}
</div>`
}
<div class="modal-overlay" hidden>
<div class="modal">
<h3>Share a Book</h3>
<div class="error-msg" hidden></div>
<div class="drop-zone">
<input type="file" accept="application/pdf" style="display:none" />
Drop a PDF here or click to browse
</div>
<label>Title *</label>
<input type="text" name="title" required />
<label>Author</label>
<input type="text" name="author" />
<label>Description</label>
<textarea name="description"></textarea>
<label>Tags (comma-separated)</label>
<input type="text" name="tags" placeholder="e.g. science, philosophy" />
<label>License</label>
<input type="text" name="license" value="CC BY-SA 4.0" />
<div class="modal-actions">
<button class="btn-cancel">Cancel</button>
<button class="btn-submit" disabled>Upload</button>
</div>
</div>
</div>
`;
this.bindEvents();
}
private bindEvents() {
if (!this.shadowRoot) return;
// Search
const searchInput = this.shadowRoot.querySelector(".search-input") as HTMLInputElement;
searchInput?.addEventListener("input", () => {
this._searchTerm = searchInput.value;
this.applyFilters();
this.updateGrid();
});
// Tags
this.shadowRoot.querySelectorAll(".tag").forEach((el) => {
el.addEventListener("click", () => {
const tag = (el as HTMLElement).dataset.tag!;
if (this._selectedTag === tag) {
this._selectedTag = null;
el.classList.remove("active");
} else {
this.shadowRoot!.querySelectorAll(".tag").forEach((t) => t.classList.remove("active"));
this._selectedTag = tag;
el.classList.add("active");
}
this.applyFilters();
this.updateGrid();
});
});
// Upload modal
const uploadBtn = this.shadowRoot.querySelector(".upload-btn");
const overlay = this.shadowRoot.querySelector(".modal-overlay") as HTMLElement;
const cancelBtn = this.shadowRoot.querySelector(".btn-cancel");
const submitBtn = this.shadowRoot.querySelector(".btn-submit") as HTMLButtonElement;
const dropZone = this.shadowRoot.querySelector(".drop-zone") as HTMLElement;
const fileInput = this.shadowRoot.querySelector('input[type="file"]') as HTMLInputElement;
const titleInput = this.shadowRoot.querySelector('input[name="title"]') as HTMLInputElement;
const errorEl = this.shadowRoot.querySelector(".error-msg") as HTMLElement;
let selectedFile: File | null = null;
uploadBtn?.addEventListener("click", () => {
overlay.hidden = false;
});
cancelBtn?.addEventListener("click", () => {
overlay.hidden = true;
selectedFile = null;
});
overlay?.addEventListener("click", (e) => {
if (e.target === overlay) overlay.hidden = true;
});
dropZone?.addEventListener("click", () => fileInput?.click());
dropZone?.addEventListener("dragover", (e) => { e.preventDefault(); dropZone.classList.add("dragover"); });
dropZone?.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
dropZone?.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const file = (e as DragEvent).dataTransfer?.files[0];
if (file?.type === "application/pdf") {
selectedFile = file;
dropZone.innerHTML = `<span class="selected">${file.name}</span>`;
if (titleInput.value) submitBtn.disabled = false;
}
});
fileInput?.addEventListener("change", () => {
if (fileInput.files?.[0]) {
selectedFile = fileInput.files[0];
dropZone.innerHTML = `<span class="selected">${selectedFile.name}</span>`;
if (titleInput.value) submitBtn.disabled = false;
}
});
titleInput?.addEventListener("input", () => {
submitBtn.disabled = !titleInput.value.trim() || !selectedFile;
});
submitBtn?.addEventListener("click", async () => {
if (!selectedFile || !titleInput.value.trim()) return;
submitBtn.disabled = true;
submitBtn.textContent = "Uploading...";
errorEl.hidden = true;
const formData = new FormData();
formData.append("pdf", selectedFile);
formData.append("title", titleInput.value.trim());
const authorInput = this.shadowRoot!.querySelector('input[name="author"]') as HTMLInputElement;
const descInput = this.shadowRoot!.querySelector('textarea[name="description"]') as HTMLTextAreaElement;
const tagsInput = this.shadowRoot!.querySelector('input[name="tags"]') as HTMLInputElement;
const licenseInput = this.shadowRoot!.querySelector('input[name="license"]') as HTMLInputElement;
if (authorInput.value) formData.append("author", authorInput.value);
if (descInput.value) formData.append("description", descInput.value);
if (tagsInput.value) formData.append("tags", tagsInput.value);
if (licenseInput.value) formData.append("license", licenseInput.value);
// Get auth token
const token = localStorage.getItem("encryptid_token");
if (!token) {
errorEl.textContent = "Please sign in first (use the identity button in the header)";
errorEl.hidden = false;
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
return;
}
try {
const res = await fetch(`/${this._spaceSlug}/rbooks/api/books`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Upload failed");
}
// Navigate to the new book
window.location.href = `/${this._spaceSlug}/rbooks/read/${data.slug}`;
} catch (e: any) {
errorEl.textContent = e.message;
errorEl.hidden = false;
submitBtn.disabled = false;
submitBtn.textContent = "Upload";
}
});
}
private updateGrid() {
// Re-render just the grid portion (lightweight update)
this.render();
}
private escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
}
customElements.define("folk-book-shelf", FolkBookShelf);

View File

@ -0,0 +1,31 @@
-- rBooks schema — community PDF library
-- Runs inside the `rbooks` schema (set by migration runner)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS books (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
author TEXT,
description TEXT,
pdf_path TEXT NOT NULL,
pdf_size_bytes BIGINT DEFAULT 0,
page_count INTEGER DEFAULT 0,
tags TEXT[] DEFAULT '{}',
license TEXT DEFAULT 'CC BY-SA 4.0',
cover_color TEXT DEFAULT '#334155',
contributor_id TEXT,
contributor_name TEXT,
status TEXT NOT NULL DEFAULT 'published',
featured BOOLEAN DEFAULT FALSE,
view_count INTEGER DEFAULT 0,
download_count INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_books_status ON books (status) WHERE status = 'published';
CREATE INDEX IF NOT EXISTS idx_books_slug ON books (slug);
CREATE INDEX IF NOT EXISTS idx_books_featured ON books (featured) WHERE featured = TRUE;
CREATE INDEX IF NOT EXISTS idx_books_created ON books (created_at DESC);

311
modules/books/mod.ts Normal file
View File

@ -0,0 +1,311 @@
/**
* Books module community PDF library with flipbook reader.
*
* Ported from rbooks-online (Next.js) to Hono routes.
* Routes are relative to mount point (/:space/books in unified, / in standalone).
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { mkdir, readFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import {
verifyEncryptIDToken,
extractToken,
} from "@encryptid/sdk/server";
const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books";
// ── Types ──
export interface BookRow {
id: string;
slug: string;
title: string;
author: string | null;
description: string | null;
pdf_path: string;
pdf_size_bytes: number;
page_count: number;
tags: string[];
license: string;
cover_color: string;
contributor_id: string | null;
contributor_name: string | null;
status: string;
featured: boolean;
view_count: number;
download_count: number;
created_at: string;
updated_at: string;
}
// ── Helpers ──
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
}
// ── Routes ──
const routes = new Hono();
// ── API: List books ──
routes.get("/api/books", async (c) => {
const search = c.req.query("search");
const tag = c.req.query("tag");
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
const offset = parseInt(c.req.query("offset") || "0");
let query = `SELECT id, slug, title, author, description, pdf_size_bytes,
page_count, tags, cover_color, contributor_name, featured,
view_count, created_at
FROM rbooks.books WHERE status = 'published'`;
const params: (string | number)[] = [];
if (search) {
params.push(`%${search}%`);
query += ` AND (title ILIKE $${params.length} OR author ILIKE $${params.length} OR description ILIKE $${params.length})`;
}
if (tag) {
params.push(tag);
query += ` AND $${params.length} = ANY(tags)`;
}
query += ` ORDER BY featured DESC, created_at DESC`;
params.push(limit);
query += ` LIMIT $${params.length}`;
params.push(offset);
query += ` OFFSET $${params.length}`;
const rows = await sql.unsafe(query, params);
return c.json({ books: rows });
});
// ── API: Upload book ──
routes.post("/api/books", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid token" }, 401);
}
const formData = await c.req.formData();
const file = formData.get("pdf") as File | null;
const title = (formData.get("title") as string || "").trim();
const author = (formData.get("author") as string || "").trim() || null;
const description = (formData.get("description") as string || "").trim() || null;
const tagsRaw = (formData.get("tags") as string || "").trim();
const license = (formData.get("license") as string || "CC BY-SA 4.0").trim();
if (!file || file.type !== "application/pdf") {
return c.json({ error: "PDF file required" }, 400);
}
if (!title) {
return c.json({ error: "Title required" }, 400);
}
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
const shortId = randomUUID().slice(0, 8);
let slug = slugify(title);
// Check slug collision
const existing = await sql.unsafe(
`SELECT 1 FROM rbooks.books WHERE slug = $1`, [slug]
);
if (existing.length > 0) {
slug = `${slug}-${shortId}`;
}
// Save PDF to disk
await mkdir(BOOKS_DIR, { recursive: true });
const filename = `${slug}.pdf`;
const filepath = resolve(BOOKS_DIR, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await Bun.write(filepath, buffer);
// Insert into DB
const rows = await sql.unsafe(
`INSERT INTO rbooks.books (slug, title, author, description, pdf_path, pdf_size_bytes, tags, license, contributor_id, contributor_name)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, slug, title, author, description, tags, created_at`,
[slug, title, author, description, filename, buffer.length, tags, license, claims.sub, claims.username || null]
);
return c.json(rows[0], 201);
});
// ── API: Get book details ──
routes.get("/api/books/:id", async (c) => {
const id = c.req.param("id");
const rows = await sql.unsafe(
`SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
[id]
);
if (rows.length === 0) return c.json({ error: "Book not found" }, 404);
// Increment view count
await sql.unsafe(
`UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`,
[rows[0].id]
);
return c.json(rows[0]);
});
// ── API: Serve PDF ──
routes.get("/api/books/:id/pdf", async (c) => {
const id = c.req.param("id");
const rows = await sql.unsafe(
`SELECT id, slug, title, pdf_path FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
[id]
);
if (rows.length === 0) return c.json({ error: "Book not found" }, 404);
const book = rows[0];
const filepath = resolve(BOOKS_DIR, book.pdf_path);
const file = Bun.file(filepath);
if (!(await file.exists())) {
return c.json({ error: "PDF file not found" }, 404);
}
// Increment download count
await sql.unsafe(
`UPDATE rbooks.books SET download_count = download_count + 1 WHERE id = $1`,
[book.id]
);
return new Response(file, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${book.slug}.pdf"`,
"Content-Length": String(file.size),
},
});
});
// ── Page: Library ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
return c.html(renderShell({
title: `${spaceSlug} — Library | rSpace`,
moduleId: "rbooks",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
scripts: `<script type="module" src="/modules/books/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/books/books.css">`,
}));
});
// ── Page: Book reader ──
routes.get("/read/:id", async (c) => {
const spaceSlug = c.req.param("space") || "personal";
const id = c.req.param("id");
const rows = await sql.unsafe(
`SELECT * FROM rbooks.books WHERE (slug = $1 OR id::text = $1) AND status = 'published'`,
[id]
);
if (rows.length === 0) {
const html = renderShell({
title: "Book not found | rSpace",
moduleId: "rbooks",
spaceSlug,
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/rbooks" style="color:#60a5fa;">Back to library</a></p></div>`,
modules: getModuleInfoList(),
});
return c.html(html, 404);
}
const book = rows[0];
// Increment view count
await sql.unsafe(
`UPDATE rbooks.books SET view_count = view_count + 1 WHERE id = $1`,
[book.id]
);
// Build the PDF URL relative to this module's mount point
const pdfUrl = `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`;
const html = renderShell({
title: `${book.title} | rSpace`,
moduleId: "rbooks",
spaceSlug,
body: `
<folk-book-reader
id="reader"
pdf-url="${pdfUrl}"
book-id="${book.slug}"
title="${escapeAttr(book.title)}"
author="${escapeAttr(book.author || '')}"
></folk-book-reader>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/books/books.css">`,
scripts: `
<script type="module">
import { FolkBookReader } from '/modules/books/folk-book-reader.js';
</script>
`,
});
return c.html(html);
});
// ── Initialize DB schema ──
async function initDB(): Promise<void> {
try {
const schemaPath = resolve(import.meta.dir, "db/schema.sql");
const schemaSql = await readFile(schemaPath, "utf-8");
await sql.unsafe(`SET search_path TO rbooks, public`);
await sql.unsafe(schemaSql);
await sql.unsafe(`SET search_path TO public`);
console.log("[Books] Database schema initialized");
} catch (e) {
console.error("[Books] Schema init failed:", e);
}
}
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── Module export ──
export const booksModule: RSpaceModule = {
id: "rbooks",
name: "rBooks",
icon: "📚",
description: "Community PDF library with flipbook reader",
routes,
standaloneDomain: "rbooks.online",
async onSpaceCreate(spaceSlug: string) {
// Books are global, not space-scoped (for now). No-op.
},
};
// Run schema init on import
initDB();

View File

@ -0,0 +1,6 @@
/* Cal module — dark theme */
folk-calendar-view {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -0,0 +1,238 @@
/**
* <folk-calendar-view> temporal coordination calendar.
*
* Month grid view with event dots, lunar phase overlay,
* event creation, and source filtering.
*/
class FolkCalendarView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private currentDate = new Date();
private events: any[] = [];
private sources: any[] = [];
private lunarData: Record<string, { phase: string; illumination: number }> = {};
private showLunar = true;
private selectedDate = "";
private selectedEvent: any = null;
private error = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadMonth();
this.render();
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/cal/);
return match ? `/${match[1]}/cal` : "";
}
private async loadMonth() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const start = `${year}-${String(month + 1).padStart(2, "0")}-01`;
const lastDay = new Date(year, month + 1, 0).getDate();
const end = `${year}-${String(month + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
const base = this.getApiBase();
try {
const [eventsRes, sourcesRes, lunarRes] = await Promise.all([
fetch(`${base}/api/events?start=${start}&end=${end}`),
fetch(`${base}/api/sources`),
fetch(`${base}/api/lunar?start=${start}&end=${end}`),
]);
if (eventsRes.ok) { const data = await eventsRes.json(); this.events = data.results || []; }
if (sourcesRes.ok) { const data = await sourcesRes.json(); this.sources = data.results || []; }
if (lunarRes.ok) { this.lunarData = await lunarRes.json(); }
} catch { /* offline fallback */ }
this.render();
}
private navigate(delta: number) {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
this.loadMonth();
}
private getMoonEmoji(phase: string): string {
const map: Record<string, string> = {
new_moon: "\u{1F311}", waxing_crescent: "\u{1F312}", first_quarter: "\u{1F313}",
waxing_gibbous: "\u{1F314}", full_moon: "\u{1F315}", waning_gibbous: "\u{1F316}",
last_quarter: "\u{1F317}", waning_crescent: "\u{1F318}",
};
return map[phase] || "";
}
private render() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const monthName = this.currentDate.toLocaleString("default", { month: "long" });
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 16px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
.toggle-btn { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 12px; }
.toggle-btn.active { border-color: #6366f1; color: #6366f1; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
.weekdays { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; margin-bottom: 4px; }
.weekday { text-align: center; font-size: 11px; color: #666; padding: 4px; font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.day {
background: #16161e; border: 1px solid #222; border-radius: 6px;
min-height: 80px; padding: 6px; cursor: pointer; position: relative;
}
.day:hover { border-color: #444; }
.day.today { border-color: #6366f1; }
.day.other-month { opacity: 0.3; }
.day-num { font-size: 12px; font-weight: 600; margin-bottom: 2px; display: flex; justify-content: space-between; }
.moon { font-size: 10px; opacity: 0.7; }
.event-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; margin: 1px; }
.event-dots { display: flex; flex-wrap: wrap; gap: 1px; }
.event-label { font-size: 9px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #aaa; line-height: 1.4; }
.event-modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-content { background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 20px; max-width: 400px; width: 90%; }
.modal-title { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
.sources { display: flex; gap: 6px; margin-bottom: 12px; flex-wrap: wrap; }
.source-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
</style>
${this.error ? `<div style="color:#ef5350;text-align:center;padding:8px">${this.esc(this.error)}</div>` : ""}
<div class="rapp-nav">
<button class="rapp-nav__back" id="prev">\u2190</button>
<span class="rapp-nav__title">${monthName} ${year}</span>
<button class="toggle-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319} Lunar</button>
<button class="rapp-nav__back" id="next">\u2192</button>
</div>
${this.sources.length > 0 ? `<div class="sources">
${this.sources.map(s => `<span class="source-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>` : ""}
<div class="weekdays">
${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map(d => `<div class="weekday">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
}
private renderDays(year: number, month: number): string {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
let html = "";
// Previous month padding
const prevDays = new Date(year, month, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="day other-month"><div class="day-num">${prevDays - i}</div></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = dateStr === todayStr;
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const lunar = this.lunarData[dateStr];
html += `<div class="day ${isToday ? "today" : ""}" data-date="${dateStr}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
</div>
${dayEvents.length > 0 ? `
<div class="event-dots">
${dayEvents.slice(0, 3).map(e => `<span class="event-dot" style="background:${e.source_color || "#6366f1"}"></span>`).join("")}
${dayEvents.length > 3 ? `<span style="font-size:9px;color:#888">+${dayEvents.length - 3}</span>` : ""}
</div>
${dayEvents.slice(0, 2).map(e => `<div class="event-label" data-event='${JSON.stringify({ id: e.id })}'>${this.esc(e.title)}</div>`).join("")}
` : ""}
</div>`;
}
// Next month padding
const totalCells = firstDay + daysInMonth;
const remaining = (7 - (totalCells % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
html += `<div class="day other-month"><div class="day-num">${i}</div></div>`;
}
return html;
}
private renderEventModal(): string {
const e = this.selectedEvent;
return `
<div class="event-modal" id="modal-overlay">
<div class="modal-content">
<button class="modal-close" id="modal-close">\u2715</button>
<div class="modal-title">${this.esc(e.title)}</div>
${e.description ? `<div class="modal-field">${this.esc(e.description)}</div>` : ""}
<div class="modal-field">When: ${new Date(e.start_time).toLocaleString()}${e.end_time ? ` \u2014 ${new Date(e.end_time).toLocaleString()}` : ""}</div>
${e.location_name ? `<div class="modal-field">Where: ${this.esc(e.location_name)}</div>` : ""}
${e.source_name ? `<div class="modal-field">Source: ${this.esc(e.source_name)}</div>` : ""}
${e.is_virtual ? `<div class="modal-field">Virtual: ${this.esc(e.virtual_platform || "")} ${e.virtual_url ? `<a href="${e.virtual_url}" target="_blank" style="color:#6366f1">Join</a>` : ""}</div>` : ""}
</div>
</div>
`;
}
private attachListeners() {
this.shadow.getElementById("prev")?.addEventListener("click", () => this.navigate(-1));
this.shadow.getElementById("next")?.addEventListener("click", () => this.navigate(1));
this.shadow.getElementById("toggle-lunar")?.addEventListener("click", () => {
this.showLunar = !this.showLunar;
this.render();
});
this.shadow.querySelectorAll("[data-event]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();
const data = JSON.parse((el as HTMLElement).dataset.event!);
this.selectedEvent = this.events.find(ev => ev.id === data.id);
this.render();
});
});
this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "modal-overlay") { this.selectedEvent = null; this.render(); }
});
this.shadow.getElementById("modal-close")?.addEventListener("click", () => {
this.selectedEvent = null; this.render();
});
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-calendar-view", FolkCalendarView);

67
modules/cal/db/schema.sql Normal file
View File

@ -0,0 +1,67 @@
-- rCal module schema
CREATE SCHEMA IF NOT EXISTS rcal;
CREATE TABLE IF NOT EXISTS rcal.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
did TEXT UNIQUE NOT NULL,
username TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rcal.calendar_sources (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
source_type TEXT NOT NULL CHECK (source_type IN ('MANUAL','ICS','CALDAV','GOOGLE','OUTLOOK','APPLE','OBSIDIAN')),
url TEXT,
color TEXT DEFAULT '#6366f1',
is_active BOOLEAN DEFAULT TRUE,
is_visible BOOLEAN DEFAULT TRUE,
sync_interval_minutes INT DEFAULT 60,
last_synced_at TIMESTAMPTZ,
owner_id UUID REFERENCES rcal.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rcal.locations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
granularity INT NOT NULL DEFAULT 5,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
parent_id UUID REFERENCES rcal.locations(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rcal.events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ,
all_day BOOLEAN DEFAULT FALSE,
timezone TEXT DEFAULT 'UTC',
rrule TEXT,
status TEXT DEFAULT 'CONFIRMED' CHECK (status IN ('TENTATIVE','CONFIRMED','CANCELLED')),
visibility TEXT DEFAULT 'DEFAULT' CHECK (visibility IN ('PUBLIC','PRIVATE','DEFAULT')),
source_id UUID REFERENCES rcal.calendar_sources(id) ON DELETE SET NULL,
location_id UUID REFERENCES rcal.locations(id) ON DELETE SET NULL,
location_name TEXT,
coordinates POINT,
location_granularity INT,
is_virtual BOOLEAN DEFAULT FALSE,
virtual_url TEXT,
virtual_platform TEXT,
r_tool_source TEXT,
r_tool_entity_id TEXT,
attendees TEXT[] DEFAULT '{}',
attendee_count INT DEFAULT 0,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rcal_events_time ON rcal.events(start_time, end_time);
CREATE INDEX IF NOT EXISTS idx_rcal_events_source ON rcal.events(source_id);
CREATE INDEX IF NOT EXISTS idx_rcal_events_rtool ON rcal.events(r_tool_source, r_tool_entity_id);
CREATE INDEX IF NOT EXISTS idx_rcal_locations_parent ON rcal.locations(parent_id);
CREATE INDEX IF NOT EXISTS idx_rcal_sources_owner ON rcal.calendar_sources(owner_id);

397
modules/cal/mod.ts Normal file
View File

@ -0,0 +1,397 @@
/**
* Cal module temporal coordination calendar.
*
* Group calendars with lunar/solar/seasonal time systems,
* location-aware events, and temporal-spatial zoom coupling.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono();
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Cal] DB schema initialized");
} catch (e) {
console.error("[Cal] DB init error:", e);
}
}
async function seedDemoIfEmpty() {
try {
const count = await sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events");
if (parseInt(count[0].cnt) > 0) return;
// Create calendar sources
const community = await sql.unsafe(
`INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible)
VALUES ('Community Events', 'MANUAL', '#6366f1', true, true) RETURNING id`
);
const sprints = await sql.unsafe(
`INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible)
VALUES ('Development Sprints', 'MANUAL', '#f59e0b', true, true) RETURNING id`
);
const communityId = community[0].id;
const sprintsId = sprints[0].id;
// Create location hierarchy
const world = await sql.unsafe(
`INSERT INTO rcal.locations (name, granularity) VALUES ('Earth', 1) RETURNING id`
);
const europe = await sql.unsafe(
`INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Europe', 2, $1, 48.8566, 2.3522) RETURNING id`,
[world[0].id]
);
const berlin = await sql.unsafe(
`INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Berlin', 4, $1, 52.52, 13.405) RETURNING id`,
[europe[0].id]
);
// Seed events — past, current week, and future
const now = new Date();
const events = [
{
title: "rSpace Launch Party",
desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.",
start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0),
sourceId: communityId, locationName: "Radiant Hall, Pittsburgh",
},
{
title: "Provider Onboarding Workshop",
desc: "Hands-on session for print providers joining the cosmolocal network.",
start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0),
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi",
},
{
title: "Weekly Community Standup",
desc: "Open standup — share what you're working on, ask for help, coordinate.",
start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45),
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi",
},
{
title: "Sprint: Module Seeding & Polish",
desc: "Focus sprint on populating demo data and improving UX across all modules.",
start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0),
sourceId: sprintsId, allDay: true,
},
{
title: "rFunds Budget Review",
desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.",
start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rfunds-review", virtualPlatform: "Jitsi",
},
{
title: "Cosmolocal Design Sprint",
desc: "Two-day design sprint on the next generation of cosmolocal tooling.",
start: daysFromNow(11, 9, 0), end: daysFromNow(12, 18, 0),
sourceId: sprintsId, locationId: berlin[0].id, locationName: "Druckwerkstatt Berlin",
},
{
title: "Q1 Retrospective",
desc: "Looking back at what we built, what worked, and what to improve.",
start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0),
sourceId: communityId, virtual: true, virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi",
},
];
for (const e of events) {
await sql.unsafe(
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, source_id,
location_id, location_name, is_virtual, virtual_url, virtual_platform)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[e.title, e.desc, e.start.toISOString(), e.end.toISOString(), e.allDay || false,
e.sourceId, e.locationId || null, e.locationName || null,
e.virtual || false, e.virtualUrl || null, e.virtualPlatform || null]
);
}
console.log("[Cal] Demo data seeded: 2 sources, 3 locations, 7 events");
} catch (e) {
console.error("[Cal] Seed error:", e);
}
}
function daysFromNow(days: number, hours: number, minutes: number): Date {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(hours, minutes, 0, 0);
return d;
}
initDB().then(seedDemoIfEmpty);
// ── API: Events ──
// GET /api/events — query events with filters
routes.get("/api/events", async (c) => {
const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query();
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (start) { where += ` AND e.start_time >= $${idx}`; params.push(start); idx++; }
if (end) { where += ` AND e.start_time <= ($${idx}::date + interval '1 day')`; params.push(end); idx++; }
if (source) { where += ` AND e.source_id = $${idx}`; params.push(source); idx++; }
if (search) { where += ` AND (e.title ILIKE $${idx} OR e.description ILIKE $${idx})`; params.push(`%${search}%`); idx++; }
if (rTool) { where += ` AND e.r_tool_source = $${idx}`; params.push(rTool); idx++; }
if (rEntityId) { where += ` AND e.r_tool_entity_id = $${idx}`; params.push(rEntityId); idx++; }
if (upcoming) {
where += ` AND e.start_time >= NOW() AND e.start_time <= NOW() + ($${idx} || ' days')::interval`;
params.push(upcoming);
idx++;
}
const rows = await sql.unsafe(
`SELECT e.*, cs.name as source_name, cs.color as source_color, l.name as location_label
FROM rcal.events e
LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
LEFT JOIN rcal.locations l ON l.id = e.location_id
${where}
ORDER BY e.start_time ASC LIMIT 500`,
params
);
return c.json({ count: rows.length, results: rows });
});
// POST /api/events — create event
routes.post("/api/events", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body;
if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);
const rows = await sql.unsafe(
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id,
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC",
source_id || null, location_id || null, location_name || null, is_virtual || false,
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub]
);
return c.json(rows[0], 201);
});
// GET /api/events/:id
routes.get("/api/events/:id", async (c) => {
const rows = await sql.unsafe(
`SELECT e.*, cs.name as source_name, cs.color as source_color
FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id
WHERE e.id = $1`,
[c.req.param("id")]
);
if (rows.length === 0) return c.json({ error: "Event not found" }, 404);
return c.json(rows[0]);
});
// PATCH /api/events/:id
routes.patch("/api/events/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const id = c.req.param("id");
const body = await c.req.json();
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
const allowed = ["title", "description", "start_time", "end_time", "all_day", "timezone",
"status", "visibility", "location_name", "is_virtual", "virtual_url"];
for (const key of allowed) {
if (body[key] !== undefined) {
fields.push(`${key} = $${idx}`);
params.push(body[key]);
idx++;
}
}
if (fields.length === 0) return c.json({ error: "No fields" }, 400);
fields.push("updated_at = NOW()");
params.push(id);
const rows = await sql.unsafe(
`UPDATE rcal.events SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (rows.length === 0) return c.json({ error: "Not found" }, 404);
return c.json(rows[0]);
});
// DELETE /api/events/:id
routes.delete("/api/events/:id", async (c) => {
const result = await sql.unsafe("DELETE FROM rcal.events WHERE id = $1 RETURNING id", [c.req.param("id")]);
if (result.length === 0) return c.json({ error: "Not found" }, 404);
return c.json({ ok: true });
});
// ── API: Sources ──
routes.get("/api/sources", async (c) => {
const { is_active, is_visible, source_type } = c.req.query();
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (is_active !== undefined) { where += ` AND is_active = $${idx}`; params.push(is_active === "true"); idx++; }
if (is_visible !== undefined) { where += ` AND is_visible = $${idx}`; params.push(is_visible === "true"); idx++; }
if (source_type) { where += ` AND source_type = $${idx}`; params.push(source_type); idx++; }
const rows = await sql.unsafe(`SELECT * FROM rcal.calendar_sources ${where} ORDER BY name`, params);
return c.json({ count: rows.length, results: rows });
});
routes.post("/api/sources", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json();
const rows = await sql.unsafe(
`INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[body.name, body.source_type || "MANUAL", body.url || null, body.color || "#6366f1",
body.is_active ?? true, body.is_visible ?? true]
);
return c.json(rows[0], 201);
});
// ── API: Locations ──
routes.get("/api/locations", async (c) => {
const { granularity, parent, search, root } = c.req.query();
let where = "WHERE 1=1";
const params: any[] = [];
let idx = 1;
if (root === "true") { where += " AND parent_id IS NULL"; }
if (granularity) { where += ` AND granularity = $${idx}`; params.push(parseInt(granularity)); idx++; }
if (parent) { where += ` AND parent_id = $${idx}`; params.push(parent); idx++; }
if (search) { where += ` AND name ILIKE $${idx}`; params.push(`%${search}%`); idx++; }
const rows = await sql.unsafe(`SELECT * FROM rcal.locations ${where} ORDER BY name`, params);
return c.json(rows);
});
routes.get("/api/locations/tree", async (c) => {
const rows = await sql.unsafe(
`WITH RECURSIVE tree AS (
SELECT id, name, granularity, parent_id, 0 as depth FROM rcal.locations WHERE parent_id IS NULL
UNION ALL
SELECT l.id, l.name, l.granularity, l.parent_id, t.depth + 1
FROM rcal.locations l JOIN tree t ON l.parent_id = t.id
)
SELECT * FROM tree ORDER BY depth, name`
);
return c.json(rows);
});
// ── API: Lunar data (computed, not stored) ──
routes.get("/api/lunar", async (c) => {
const { start, end } = c.req.query();
if (!start || !end) return c.json({ error: "start and end required" }, 400);
// Simple lunar phase approximation based on synodic month
const SYNODIC_MONTH = 29.53059;
const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime();
const phases: Record<string, { phase: string; illumination: number }> = {};
const startDate = new Date(start);
const endDate = new Date(end);
const current = new Date(startDate);
while (current <= endDate) {
const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24);
const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
const fraction = lunation / SYNODIC_MONTH;
const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction));
let phase = "waxing_crescent";
if (fraction < 0.0625) phase = "new_moon";
else if (fraction < 0.1875) phase = "waxing_crescent";
else if (fraction < 0.3125) phase = "first_quarter";
else if (fraction < 0.4375) phase = "waxing_gibbous";
else if (fraction < 0.5625) phase = "full_moon";
else if (fraction < 0.6875) phase = "waning_gibbous";
else if (fraction < 0.8125) phase = "last_quarter";
else if (fraction < 0.9375) phase = "waning_crescent";
else phase = "new_moon";
phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 };
current.setDate(current.getDate() + 1);
}
return c.json(phases);
});
// ── API: Stats ──
routes.get("/api/stats", async (c) => {
const [eventCount, sourceCount, locationCount] = await Promise.all([
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"),
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.calendar_sources WHERE is_active = true"),
sql.unsafe("SELECT count(*)::int as cnt FROM rcal.locations"),
]);
return c.json({
events: eventCount[0]?.cnt || 0,
sources: sourceCount[0]?.cnt || 0,
locations: locationCount[0]?.cnt || 0,
});
});
// ── API: Context (r* tool bridge) ──
routes.get("/api/context/:tool", async (c) => {
const tool = c.req.param("tool");
const entityId = c.req.query("entityId");
if (!entityId) return c.json({ error: "entityId required" }, 400);
const rows = await sql.unsafe(
"SELECT * FROM rcal.events WHERE r_tool_source = $1 AND r_tool_entity_id = $2 ORDER BY start_time",
[tool, entityId]
);
return c.json({ count: rows.length, results: rows });
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Calendar | rSpace`,
moduleId: "rcal",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
}));
});
export const calModule: RSpaceModule = {
id: "rcal",
name: "rCal",
icon: "\u{1F4C5}",
description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
routes,
standaloneDomain: "rcal.online",
};

74
modules/canvas/mod.ts Normal file
View File

@ -0,0 +1,74 @@
/**
* Canvas module the collaborative infinite canvas.
*
* This is the original rSpace canvas restructured as an rSpace module.
* Routes are relative to the mount point (/:space/canvas in unified mode,
* / in standalone mode).
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const DIST_DIR = resolve(import.meta.dir, "../../dist");
const routes = new Hono();
// GET / — serve the canvas page wrapped in shell
routes.get("/", async (c) => {
const spaceSlug = c.req.param("space") || c.req.query("space") || "demo";
// Read the canvas page template from dist
const canvasFile = Bun.file(resolve(DIST_DIR, "canvas-module.html"));
let canvasBody = "";
if (await canvasFile.exists()) {
canvasBody = await canvasFile.text();
} else {
// Fallback: serve full canvas.html directly if module template not built yet
const fallbackFile = Bun.file(resolve(DIST_DIR, "canvas.html"));
if (await fallbackFile.exists()) {
return new Response(fallbackFile, {
headers: { "Content-Type": "text/html" },
});
}
canvasBody = `<div style="padding:2rem;text-align:center;color:#64748b;">Canvas loading...</div>`;
}
const html = renderShell({
title: `${spaceSlug} — Canvas | rSpace`,
moduleId: "rspace",
spaceSlug,
body: canvasBody,
modules: getModuleInfoList(),
theme: "dark",
scripts: `<script type="module" src="/canvas-module.js"></script>`,
});
return c.html(html);
});
export const canvasModule: RSpaceModule = {
id: "rspace",
name: "rSpace",
icon: "🎨",
description: "Real-time collaborative canvas",
routes,
feeds: [
{
id: "shapes",
name: "Canvas Shapes",
kind: "data",
description: "All shapes on this canvas layer — notes, embeds, arrows, etc.",
filterable: true,
},
{
id: "connections",
name: "Shape Connections",
kind: "data",
description: "Arrow connections between shapes — the canvas graph",
},
],
acceptsFeeds: ["economic", "trust", "data", "attention", "governance", "resource"],
};

View File

@ -0,0 +1,6 @@
/* Cart module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -0,0 +1,152 @@
/**
* <folk-cart-shop> browse catalog, view orders, trigger fulfillment.
* Shows catalog items, order creation flow, and order status tracking.
*/
class FolkCartShop extends HTMLElement {
private shadow: ShadowRoot;
private catalog: any[] = [];
private orders: any[] = [];
private view: "catalog" | "orders" = "catalog";
private loading = true;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadData();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/cart` : "/demo/cart";
}
private async loadData() {
this.loading = true;
this.render();
try {
const [catRes, ordRes] = await Promise.all([
fetch(`${this.getApiBase()}/api/catalog?limit=50`),
fetch(`${this.getApiBase()}/api/orders?limit=20`),
]);
const catData = await catRes.json();
const ordData = await ordRes.json();
this.catalog = catData.entries || [];
this.orders = ordData.orders || [];
} catch (e) {
console.error("Failed to load cart data:", e);
}
this.loading = false;
this.render();
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
.tabs { display: flex; gap: 0.5rem; }
.tab { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; }
.tab:hover { border-color: #475569; color: #f1f5f9; }
.tab.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; }
.card:hover { border-color: #475569; }
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.5rem; }
.card-meta { color: #94a3b8; font-size: 0.8125rem; margin-bottom: 0.5rem; }
.tag { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.6875rem; margin-right: 0.25rem; }
.tag-type { background: rgba(99,102,241,0.1); color: #818cf8; }
.tag-cap { background: rgba(34,197,94,0.1); color: #4ade80; }
.dims { color: #64748b; font-size: 0.75rem; margin-top: 0.5rem; }
.status { padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.6875rem; font-weight: 500; }
.status-pending { background: rgba(251,191,36,0.15); color: #fbbf24; }
.status-paid { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-active { background: rgba(34,197,94,0.15); color: #4ade80; }
.status-completed { background: rgba(99,102,241,0.15); color: #a5b4fc; }
.status-cancelled { background: rgba(239,68,68,0.15); color: #f87171; }
.order-card { display: flex; justify-content: space-between; align-items: center; }
.order-info { flex: 1; }
.order-price { color: #f1f5f9; font-weight: 600; font-size: 1.125rem; }
.empty { text-align: center; padding: 3rem; color: #64748b; font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Shop</span>
<div class="tabs">
<button class="tab ${this.view === 'catalog' ? 'active' : ''}" data-view="catalog">\u{1F4E6} Catalog (${this.catalog.length})</button>
<button class="tab ${this.view === 'orders' ? 'active' : ''}" data-view="orders">\u{1F4CB} Orders (${this.orders.length})</button>
</div>
</div>
${this.loading ? `<div class="loading">\u23F3 Loading...</div>` :
this.view === "catalog" ? this.renderCatalog() : this.renderOrders()}
`;
this.shadow.querySelectorAll(".tab").forEach((el) => {
el.addEventListener("click", () => {
this.view = ((el as HTMLElement).dataset.view || "catalog") as "catalog" | "orders";
this.render();
});
});
}
private renderCatalog(): string {
if (this.catalog.length === 0) {
return `<div class="empty">No items in the catalog yet. Ingest artifacts from rPubs or Swag Designer to list them here.</div>`;
}
return `<div class="grid">
${this.catalog.map((entry) => `
<div class="card">
<h3 class="card-title">${this.esc(entry.title || "Untitled")}</h3>
<div class="card-meta">
${entry.product_type ? `<span class="tag tag-type">${this.esc(entry.product_type)}</span>` : ""}
${(entry.required_capabilities || []).map((cap: string) => `<span class="tag tag-cap">${this.esc(cap)}</span>`).join("")}
</div>
${entry.description ? `<div class="card-meta">${this.esc(entry.description)}</div>` : ""}
${entry.dimensions ? `<div class="dims">${entry.dimensions.width_mm}x${entry.dimensions.height_mm}mm</div>` : ""}
<div style="margin-top:0.5rem"><span class="status status-${entry.status}">${entry.status}</span></div>
</div>
`).join("")}
</div>`;
}
private renderOrders(): string {
if (this.orders.length === 0) {
return `<div class="empty">No orders yet.</div>`;
}
return `<div class="grid">
${this.orders.map((order) => `
<div class="card">
<div class="order-card">
<div class="order-info">
<h3 class="card-title">${this.esc(order.artifact_title || "Order")}</h3>
<div class="card-meta">
${order.provider_name ? `Provider: ${this.esc(order.provider_name)}` : ""}
${order.quantity > 1 ? ` \u2022 Qty: ${order.quantity}` : ""}
</div>
<span class="status status-${order.status}">${order.status}</span>
</div>
<div class="order-price">$${parseFloat(order.total_price || 0).toFixed(2)}</div>
</div>
</div>
`).join("")}
</div>`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-cart-shop", FolkCartShop);

View File

@ -0,0 +1,58 @@
-- rCart schema — catalog entries, orders, payment splits
-- Inside rSpace shared DB, schema: rcart
CREATE TABLE IF NOT EXISTS rcart.catalog_entries (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
artifact_id UUID NOT NULL UNIQUE,
artifact JSONB NOT NULL,
title TEXT NOT NULL,
product_type TEXT,
required_capabilities TEXT[] DEFAULT '{}',
substrates TEXT[] DEFAULT '{}',
creator_id TEXT,
source_space TEXT,
tags TEXT[] DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'paused', 'sold_out', 'removed')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_catalog_status ON rcart.catalog_entries (status) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS idx_catalog_capabilities ON rcart.catalog_entries USING gin (required_capabilities);
CREATE INDEX IF NOT EXISTS idx_catalog_tags ON rcart.catalog_entries USING gin (tags);
CREATE INDEX IF NOT EXISTS idx_catalog_source_space ON rcart.catalog_entries (source_space);
CREATE INDEX IF NOT EXISTS idx_catalog_product_type ON rcart.catalog_entries (product_type);
CREATE TABLE IF NOT EXISTS rcart.orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
catalog_entry_id UUID NOT NULL REFERENCES rcart.catalog_entries(id),
artifact_id UUID NOT NULL,
buyer_id TEXT,
buyer_location JSONB,
buyer_contact JSONB,
provider_id UUID,
provider_name TEXT,
provider_distance_km DOUBLE PRECISION,
quantity INTEGER NOT NULL DEFAULT 1,
production_cost NUMERIC(10,2),
creator_payout NUMERIC(10,2),
community_payout NUMERIC(10,2),
total_price NUMERIC(10,2),
currency TEXT DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'paid', 'accepted', 'in_production', 'ready', 'shipped', 'completed', 'cancelled'
)),
payment_method TEXT DEFAULT 'manual',
payment_tx TEXT,
payment_network TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
paid_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_orders_status ON rcart.orders (status);
CREATE INDEX IF NOT EXISTS idx_orders_provider ON rcart.orders (provider_id);
CREATE INDEX IF NOT EXISTS idx_orders_buyer ON rcart.orders (buyer_id);
CREATE INDEX IF NOT EXISTS idx_orders_catalog ON rcart.orders (catalog_entry_id);

41
modules/cart/flow.ts Normal file
View File

@ -0,0 +1,41 @@
/**
* Flow Service integration deposits order revenue into the TBFF flow
* for automatic threshold-based splits (provider / creator / community).
*/
const FLOW_SERVICE_URL = process.env.FLOW_SERVICE_URL || "http://payment-flow:3010";
const FLOW_ID = process.env.FLOW_ID || "";
const FUNNEL_ID = process.env.FUNNEL_ID || "";
function toUsdcUnits(dollars: number | string): string {
const d = typeof dollars === "string" ? parseFloat(dollars) : dollars;
return Math.round(d * 1e6).toString();
}
export async function depositOrderRevenue(
totalPrice: number | string,
orderId: string
): Promise<void> {
if (!FLOW_ID || !FUNNEL_ID) return;
const amount = toUsdcUnits(totalPrice);
const url = `${FLOW_SERVICE_URL}/api/flows/${FLOW_ID}/deposit`;
try {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ funnelId: FUNNEL_ID, amount, source: "wallet" }),
});
if (resp.ok) {
const data = await resp.json() as { transaction?: { id?: string } };
console.log(`[Cart] Flow deposit OK: order=${orderId} amount=${amount} tx=${data.transaction?.id}`);
} else {
const text = await resp.text();
console.error(`[Cart] Flow deposit failed (${resp.status}): ${text}`);
}
} catch (err) {
console.error("[Cart] Flow deposit error:", err instanceof Error ? err.message : err);
}
}

463
modules/cart/mod.ts Normal file
View File

@ -0,0 +1,463 @@
/**
* Cart module cosmolocal print-on-demand shop.
*
* Ported from /opt/apps/rcart/ (Express Hono).
* Handles catalog (artifact listings), orders, fulfillment resolution.
* Integrates with provider-registry for provider matching and flow-service for revenue splits.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono();
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Cart] DB schema initialized");
} catch (e) {
console.error("[Cart] DB init error:", e);
}
}
initDB();
// Provider registry URL (for fulfillment resolution)
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";
function getProviderUrl(): string {
// In unified mode, providers module is co-located — call its routes directly via internal URL
// In standalone mode, use PROVIDER_REGISTRY_URL env
return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
}
// ── CATALOG ROUTES ──
// POST /api/catalog/ingest — Add artifact to catalog
routes.post("/api/catalog/ingest", async (c) => {
const artifact = await c.req.json();
if (!artifact.id || !artifact.schema_version || !artifact.type) {
return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
}
if (artifact.type !== "print-ready") {
return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
}
if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
}
const existing = await sql.unsafe("SELECT id FROM rcart.catalog_entries WHERE artifact_id = $1", [artifact.id]);
if (existing.length > 0) {
return c.json({ error: "Artifact already listed", catalog_entry_id: existing[0].id }, 409);
}
const result = await sql.unsafe(
`INSERT INTO rcart.catalog_entries (
artifact_id, artifact, title, product_type,
required_capabilities, substrates, creator_id,
source_space, tags
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, artifact_id, title, product_type, status, created_at`,
[
artifact.id, JSON.stringify(artifact),
artifact.payload?.title || "Untitled",
artifact.spec?.product_type || null,
artifact.spec?.required_capabilities || [],
artifact.spec?.substrates || [],
artifact.creator?.id || null,
artifact.source_space || null,
artifact.payload?.tags || [],
]
);
return c.json(result[0], 201);
});
// GET /api/catalog — Browse catalog
routes.get("/api/catalog", async (c) => {
const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = ["status = 'active'"];
const params: any[] = [];
let paramIdx = 1;
if (product_type) {
conditions.push(`product_type = $${paramIdx}`);
params.push(product_type);
paramIdx++;
}
if (capability) {
conditions.push(`required_capabilities && $${paramIdx}`);
params.push(capability.split(","));
paramIdx++;
}
if (tag) {
conditions.push(`$${paramIdx} = ANY(tags)`);
params.push(tag);
paramIdx++;
}
if (source_space) {
conditions.push(`source_space = $${paramIdx}`);
params.push(source_space);
paramIdx++;
}
if (q) {
conditions.push(`title ILIKE $${paramIdx}`);
params.push(`%${q}%`);
paramIdx++;
}
const where = conditions.join(" AND ");
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const [result, countResult] = await Promise.all([
sql.unsafe(
`SELECT id, artifact_id, title, product_type,
required_capabilities, tags, source_space,
artifact->'payload'->>'description' as description,
artifact->'pricing' as pricing,
artifact->'spec'->'dimensions' as dimensions,
status, created_at
FROM rcart.catalog_entries
WHERE ${where}
ORDER BY created_at DESC
LIMIT ${limitNum} OFFSET ${offsetNum}`,
params
),
sql.unsafe(`SELECT count(*) FROM rcart.catalog_entries WHERE ${where}`, params),
]);
return c.json({ entries: result, total: parseInt(countResult[0].count as string), limit: limitNum, offset: offsetNum });
});
// GET /api/catalog/:id — Single catalog entry
routes.get("/api/catalog/:id", async (c) => {
const id = c.req.param("id");
const result = await sql.unsafe(
"SELECT * FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
[id]
);
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
const row = result[0];
return c.json({ id: row.id, artifact: row.artifact, status: row.status, created_at: row.created_at, updated_at: row.updated_at });
});
// PATCH /api/catalog/:id — Update listing status
routes.patch("/api/catalog/:id", async (c) => {
const { status } = await c.req.json();
const valid = ["active", "paused", "sold_out", "removed"];
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
const result = await sql.unsafe(
"UPDATE rcart.catalog_entries SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING id, status",
[status, c.req.param("id")]
);
if (result.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
return c.json(result[0]);
});
// ── ORDER ROUTES ──
// POST /api/orders — Create an order
routes.post("/api/orders", async (c) => {
// Optional auth — set buyer_did from claims if authenticated
const token = extractToken(c.req.raw.headers);
let buyerDid: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
}
const body = await c.req.json();
const {
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
provider_id, provider_name, provider_distance_km,
quantity = 1, production_cost, creator_payout, community_payout,
total_price, currency = "USD", payment_method = "manual",
payment_tx, payment_network,
} = body;
if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);
const entryResult = await sql.unsafe(
"SELECT id, artifact_id FROM rcart.catalog_entries WHERE id = $1 OR artifact_id = $1",
[catalog_entry_id || artifact_id]
);
if (entryResult.length === 0) return c.json({ error: "Catalog entry not found" }, 404);
const entry = entryResult[0];
// x402 detection
const x402Header = c.req.header("x-payment");
const effectiveMethod = x402Header ? "x402" : payment_method;
const initialStatus = x402Header ? "paid" : "pending";
const result = await sql.unsafe(
`INSERT INTO rcart.orders (
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
provider_id, provider_name, provider_distance_km,
quantity, production_cost, creator_payout, community_payout,
total_price, currency, status, payment_method, payment_tx, payment_network
${initialStatus === "paid" ? ", paid_at" : ""}
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18
${initialStatus === "paid" ? ", NOW()" : ""})
RETURNING *`,
[
entry.id, entry.artifact_id,
buyerDid || buyer_id || null,
buyer_location ? JSON.stringify(buyer_location) : null,
buyer_contact ? JSON.stringify(buyer_contact) : null,
provider_id, provider_name || null, provider_distance_km || null,
quantity, production_cost || null, creator_payout || null, community_payout || null,
total_price, currency, initialStatus, effectiveMethod,
payment_tx || null, payment_network || null,
]
);
const order = result[0];
if (initialStatus === "paid") {
depositOrderRevenue(total_price, order.id);
}
return c.json(order, 201);
});
// GET /api/orders — List orders
routes.get("/api/orders", async (c) => {
// Optional auth — filter by buyer if authenticated
const token = extractToken(c.req.raw.headers);
let authedBuyer: string | null = null;
if (token) {
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
}
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
if (effectiveBuyerId) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(effectiveBuyerId); paramIdx++; }
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limitNum = Math.min(parseInt(limit) || 50, 100);
const offsetNum = parseInt(offset) || 0;
const result = await sql.unsafe(
`SELECT o.*, c.title as artifact_title, c.product_type
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
${where} ORDER BY o.created_at DESC LIMIT ${limitNum} OFFSET ${offsetNum}`,
params
);
return c.json({ orders: result });
});
// GET /api/orders/:id — Single order
routes.get("/api/orders/:id", async (c) => {
const result = await sql.unsafe(
`SELECT o.*, c.artifact as artifact_envelope, c.title as artifact_title
FROM rcart.orders o JOIN rcart.catalog_entries c ON c.id = o.catalog_entry_id
WHERE o.id = $1`,
[c.req.param("id")]
);
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
return c.json(result[0]);
});
// PATCH /api/orders/:id/status — Update order status
routes.patch("/api/orders/:id/status", async (c) => {
const body = await c.req.json();
const { status, payment_tx, payment_network } = body;
const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);
const timestampField: Record<string, string> = { paid: "paid_at", accepted: "accepted_at", completed: "completed_at" };
const extraSet = timestampField[status] ? `, ${timestampField[status]} = NOW()` : "";
// Use parameterized query for payment info
let paymentSet = "";
const params: any[] = [status, c.req.param("id")];
if (status === "paid" && payment_tx) {
paymentSet = `, payment_tx = $3, payment_network = $4`;
params.push(payment_tx, payment_network || null);
}
const result = await sql.unsafe(
`UPDATE rcart.orders SET status = $1, updated_at = NOW()${extraSet}${paymentSet} WHERE id = $2 RETURNING *`,
params
);
if (result.length === 0) return c.json({ error: "Order not found" }, 404);
const updated = result[0];
if (status === "paid" && updated.total_price) {
depositOrderRevenue(updated.total_price, c.req.param("id"));
}
return c.json(updated);
});
// ── FULFILLMENT ROUTES ──
function round2(n: number): number {
return Math.round(n * 100) / 100;
}
interface ProviderMatch {
id: string; name: string; distance_km: number;
location: { city: string; region: string; country: string };
turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
wallet: string;
}
function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
const spec = artifact.spec as Record<string, unknown> | undefined;
const capabilities = (spec?.required_capabilities as string[]) || [];
const pages = (spec?.pages as number) || 1;
const breakdown: { label: string; amount: number }[] = [];
for (const cap of capabilities) {
const capPricing = provider.pricing?.[cap];
if (!capPricing) continue;
const basePrice = capPricing.base_price || 0;
let perUnit = capPricing.per_unit || 0;
const unitLabel = capPricing.per_unit_label || "per unit";
if (capPricing.volume_breaks) {
for (const vb of capPricing.volume_breaks) {
if (quantity >= vb.min_qty) perUnit = vb.per_unit;
}
}
let units = quantity;
if (unitLabel.includes("page")) units = pages * quantity;
if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
}
if (breakdown.length === 0) {
breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
}
return breakdown;
}
// POST /api/fulfill/resolve — Find fulfillment options
routes.post("/api/fulfill/resolve", async (c) => {
const body = await c.req.json();
const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;
if (!buyer_location?.lat || !buyer_location?.lng) {
return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
}
if (!artifact_id && !catalog_entry_id) {
return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
}
const entryResult = await sql.unsafe(
"SELECT * FROM rcart.catalog_entries WHERE (artifact_id = $1 OR id = $1) AND status = 'active'",
[artifact_id || catalog_entry_id]
);
if (entryResult.length === 0) return c.json({ error: "Artifact not found in catalog" }, 404);
const entry = entryResult[0];
const artifact = entry.artifact;
const capabilities = artifact.spec?.required_capabilities || [];
const substrates = artifact.spec?.substrates || [];
if (capabilities.length === 0) {
return c.json({ error: "Artifact has no required_capabilities" }, 400);
}
// Query provider registry (internal module or external service)
const providerUrl = getProviderUrl();
const params = new URLSearchParams({
capabilities: capabilities.join(","),
lat: String(buyer_location.lat),
lng: String(buyer_location.lng),
});
if (substrates.length > 0) params.set("substrates", substrates.join(","));
let providers: ProviderMatch[];
try {
const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
const data = await resp.json() as { matches?: ProviderMatch[] };
providers = data.matches || [];
} catch (err) {
return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
}
if (providers.length === 0) {
return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
}
const options = providers.map((provider) => {
const costBreakdown = composeCost(artifact, provider, quantity);
const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
const pricing = artifact.pricing || {};
const creatorPct = (pricing.creator_share_pct || 30) / 100;
const communityPct = (pricing.community_share_pct || 0) / 100;
const markupMultiplier = 1 / (1 - creatorPct - communityPct);
const totalPrice = productionCost * markupMultiplier;
const creatorPayout = totalPrice * creatorPct;
const communityPayout = totalPrice * communityPct;
return {
provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
production_cost: round2(productionCost),
creator_payout: round2(creatorPayout),
community_payout: round2(communityPayout),
total_price: round2(totalPrice),
currency: "USD",
turnaround_days: provider.turnaround?.standard_days || 5,
cost_breakdown: costBreakdown,
};
});
options.sort((a, b) => a.total_price - b.total_price);
return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
});
// ── Page route: shop ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `Shop | rSpace`,
moduleId: "rcart",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-cart-shop space="${space}"></folk-cart-shop>`,
scripts: `<script type="module" src="/modules/cart/folk-cart-shop.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/cart/cart.css">`,
}));
});
export const cartModule: RSpaceModule = {
id: "rcart",
name: "rCart",
icon: "\u{1F6D2}",
description: "Cosmolocal print-on-demand shop",
routes,
standaloneDomain: "rcart.online",
};

View File

@ -0,0 +1,6 @@
/* Choices module theme */
body[data-theme="light"] main {
background: #0f172a;
min-height: calc(100vh - 56px);
padding: 0;
}

View File

@ -0,0 +1,126 @@
/**
* <folk-choices-dashboard> lists choice shapes (polls, rankings, spider charts)
* from the current space and links to the canvas to create/interact with them.
*/
class FolkChoicesDashboard extends HTMLElement {
private shadow: ShadowRoot;
private choices: any[] = [];
private loading = true;
private space: string;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this.space = this.getAttribute("space") || "demo";
}
connectedCallback() {
this.render();
this.loadChoices();
}
private getApiBase(): string {
const path = window.location.pathname;
const parts = path.split("/").filter(Boolean);
return parts.length >= 2 ? `/${parts[0]}/choices` : "/demo/choices";
}
private async loadChoices() {
this.loading = true;
this.render();
try {
const res = await fetch(`${this.getApiBase()}/api/choices`);
const data = await res.json();
this.choices = data.choices || [];
} catch (e) {
console.error("Failed to load choices:", e);
}
this.loading = false;
this.render();
}
private render() {
const typeIcons: Record<string, string> = {
"folk-choice-vote": "\u2611",
"folk-choice-rank": "\uD83D\uDCCA",
"folk-choice-spider": "\uD83D\uDD78",
};
const typeLabels: Record<string, string> = {
"folk-choice-vote": "Poll",
"folk-choice-rank": "Ranking",
"folk-choice-spider": "Spider Chart",
};
this.shadow.innerHTML = `
<style>
:host { display: block; padding: 1.5rem; }
.rapp-nav { display: flex; gap: 8px; align-items: center; margin-bottom: 1rem; min-height: 36px; }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; }
.create-btns { display: flex; gap: 0.5rem; }
.create-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 0.875rem; text-decoration: none; }
.create-btn:hover { border-color: #6366f1; color: #f1f5f9; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 1.25rem; cursor: pointer; text-decoration: none; display: block; }
.card:hover { border-color: #6366f1; }
.card-icon { font-size: 1.5rem; margin-bottom: 0.5rem; }
.card-title { color: #f1f5f9; font-weight: 600; font-size: 1rem; margin: 0 0 0.25rem; }
.card-type { color: #818cf8; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
.card-meta { color: #94a3b8; font-size: 0.8125rem; }
.stat { display: inline-block; margin-right: 1rem; }
.empty { text-align: center; padding: 3rem; color: #64748b; }
.empty-icon { font-size: 3rem; margin-bottom: 1rem; }
.empty p { margin: 0.5rem 0; font-size: 0.875rem; }
.loading { text-align: center; padding: 3rem; color: #94a3b8; }
.info { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; color: #a5b4fc; font-size: 0.8125rem; }
</style>
<div class="rapp-nav">
<span class="rapp-nav__title">Choices</span>
<div class="create-btns">
<a class="create-btn" href="/${this.space}/rspace" title="Open canvas to create choices">\u2795 New on Canvas</a>
</div>
</div>
<div class="info">
Choice tools (Polls, Rankings, Spider Charts) live on the collaborative canvas.
Create them there and they'll appear here for quick access.
</div>
${this.loading ? `<div class="loading">\u23F3 Loading choices...</div>` :
this.choices.length === 0 ? this.renderEmpty() : this.renderGrid(typeIcons, typeLabels)}
`;
}
private renderEmpty(): string {
return `<div class="empty">
<div class="empty-icon">\u2611</div>
<p>No choices in this space yet.</p>
<p>Open the <a href="/${this.space}/rspace" style="color:#818cf8">canvas</a> and use the Poll, Rank, or Spider buttons to create one.</p>
</div>`;
}
private renderGrid(icons: Record<string, string>, labels: Record<string, string>): string {
return `<div class="grid">
${this.choices.map((ch) => `
<a class="card" href="/${this.space}/rspace">
<div class="card-icon">${icons[ch.type] || "\u2611"}</div>
<div class="card-type">${labels[ch.type] || ch.type}</div>
<h3 class="card-title">${this.esc(ch.title)}</h3>
<div class="card-meta">
<span class="stat">${ch.optionCount} options</span>
<span class="stat">${ch.voteCount} responses</span>
</div>
</a>
`).join("")}
</div>`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
}
customElements.define("folk-choices-dashboard", FolkChoicesDashboard);

80
modules/choices/mod.ts Normal file
View File

@ -0,0 +1,80 @@
/**
* Choices module voting, ranking, and multi-criteria scoring tools.
*
* The folk-choice-* web components live in lib/ (shared with canvas).
* This module provides:
* - A page listing choice shapes in the current space
* - API to query choice shapes from the Automerge store
* - Links to create new choices on the canvas
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { getDocumentData } from "../../server/community-store";
const routes = new Hono();
// GET /api/choices — list choice shapes in the current space
routes.get("/api/choices", async (c) => {
const space = c.req.param("space") || c.req.query("space") || "demo";
const docData = getDocumentData(space);
if (!docData?.shapes) {
return c.json({ choices: [], total: 0 });
}
const choiceTypes = ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"];
const choices: any[] = [];
for (const [id, shape] of Object.entries(docData.shapes as Record<string, any>)) {
if (shape.forgotten) continue;
if (choiceTypes.includes(shape.type)) {
choices.push({
id,
type: shape.type,
title: shape.title || "Untitled",
mode: shape.mode,
optionCount: (shape.options || []).length,
voteCount: (shape.votes || shape.rankings || shape.scores || []).length,
createdAt: shape.createdAt,
});
}
}
return c.json({ choices, total: choices.length });
});
// GET / — choices page
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Choices | rSpace`,
moduleId: "rchoices",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-choices-dashboard space="${spaceSlug}"></folk-choices-dashboard>`,
scripts: `<script type="module" src="/modules/choices/folk-choices-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/choices/choices.css">`,
}));
});
export const choicesModule: RSpaceModule = {
id: "rchoices",
name: "rChoices",
icon: "☑",
description: "Polls, rankings, and multi-criteria scoring",
routes,
standaloneDomain: "rchoices.online",
feeds: [
{
id: "poll-results",
name: "Poll Results",
kind: "governance",
description: "Live poll, ranking, and scoring outcomes",
emits: ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"],
},
],
acceptsFeeds: ["data", "governance"],
};

View File

@ -0,0 +1,5 @@
/* Data module — layout wrapper */
folk-analytics-view {
display: block;
padding: 1.5rem;
}

View File

@ -0,0 +1,120 @@
/**
* folk-analytics-view Privacy-first analytics dashboard overview.
*
* Shows tracked apps, stats, and a link to the full Umami dashboard.
*/
class FolkAnalyticsView extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private stats: any = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadStats();
}
private async loadStats() {
try {
const base = window.location.pathname.replace(/\/$/, "");
const resp = await fetch(`${base}/api/stats`);
if (resp.ok) {
this.stats = await resp.json();
}
} catch { /* ignore */ }
this.render();
}
private render() {
const stats = this.stats || { trackedApps: 17, cookiesSet: 0, scriptSize: "~2KB", selfHosted: true, apps: [], dashboardUrl: "https://analytics.rspace.online" };
this.shadow.innerHTML = `
<style>
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: #e2e8f0; }
.container { max-width: 800px; margin: 0 auto; }
.desc { color: #94a3b8; font-size: 14px; line-height: 1.6; max-width: 600px; margin-bottom: 1.5rem; }
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; margin-bottom: 2rem; }
.stat { text-align: center; background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.25rem; }
.stat-value { font-size: 1.75rem; font-weight: 700; color: #22d3ee; }
.stat-label { font-size: 0.75rem; color: #64748b; margin-top: 0.25rem; }
.pillars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2rem; }
.pillar { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 1.5rem; }
.pillar-icon { width: 40px; height: 40px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: 700; margin-bottom: 0.75rem; }
.pillar-icon.zk { background: rgba(34,211,238,0.1); color: #22d3ee; }
.pillar-icon.lf { background: rgba(129,140,248,0.1); color: #818cf8; }
.pillar-icon.sh { background: rgba(52,211,153,0.1); color: #34d399; }
.pillar h3 { font-size: 1rem; font-weight: 600; margin-bottom: 0.5rem; }
.pillar p { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; }
.apps-section { margin-bottom: 2rem; }
.apps-title { font-size: 1rem; font-weight: 600; margin-bottom: 1rem; }
.apps-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.app-chip { padding: 0.35rem 0.75rem; background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 20px; font-size: 0.8rem; color: #94a3b8; }
.cta { padding: 1.5rem 0; border-top: 1px solid #1e293b; }
.cta a { display: inline-block; padding: 0.6rem 1.5rem; background: #22d3ee; color: #0f172a; border-radius: 8px; font-weight: 600; text-decoration: none; font-size: 0.85rem; }
.cta a:hover { opacity: 0.85; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.pillars { grid-template-columns: 1fr; }
}
</style>
<div class="container">
<p class="desc">Zero-knowledge, cookieless, self-hosted analytics for the r* ecosystem. Know how your tools are used without compromising anyone's privacy.</p>
<div class="stats-grid">
<div class="stat">
<div class="stat-value">${stats.trackedApps}</div>
<div class="stat-label">Apps Tracked</div>
</div>
<div class="stat">
<div class="stat-value">${stats.cookiesSet}</div>
<div class="stat-label">Cookies Set</div>
</div>
<div class="stat">
<div class="stat-value">${stats.scriptSize}</div>
<div class="stat-label">Script Size</div>
</div>
<div class="stat">
<div class="stat-value">100%</div>
<div class="stat-label">Self-Hosted</div>
</div>
</div>
<div class="pillars">
<div class="pillar">
<div class="pillar-icon zk">ZK</div>
<h3>Zero-Knowledge Privacy</h3>
<p>No cookies. No fingerprinting. No personal data. Each page view is anonymous. GDPR compliant by architecture.</p>
</div>
<div class="pillar">
<div class="pillar-icon lf">LF</div>
<h3>Local-First Data</h3>
<p>Analytics data never leaves your infrastructure. No third-party servers, no cloud dependencies.</p>
</div>
<div class="pillar">
<div class="pillar-icon sh">SH</div>
<h3>Self-Hosted</h3>
<p>Full control over data retention, access, and lifecycle. Powered by Umami.</p>
</div>
</div>
<div class="apps-section">
<div class="apps-title">Tracked Apps</div>
<div class="apps-grid">
${(stats.apps || []).map((a: string) => `<span class="app-chip">${a}</span>`).join("")}
</div>
</div>
<div class="cta">
<a href="${stats.dashboardUrl}" target="_blank" rel="noopener">Open Full Dashboard &rarr;</a>
</div>
</div>
`;
}
}
customElements.define("folk-analytics-view", FolkAnalyticsView);

158
modules/data/mod.ts Normal file
View File

@ -0,0 +1,158 @@
/**
* Data module privacy-first analytics dashboard.
*
* Lightweight module that shows analytics stats from the
* self-hosted Umami instance. No database proxies to Umami API.
*/
import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
const routes = new Hono();
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
const TRACKED_APPS = [
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
"rTrips", "rTube", "rWork", "rNetwork", "rData",
];
// ── API routes ──
// GET /api/info — module info
routes.get("/api/info", (c) => {
return c.json({
module: "data",
name: "rData",
umamiUrl: UMAMI_URL,
umamiConfigured: !!UMAMI_URL,
features: ["privacy-first", "cookieless", "self-hosted"],
trackedApps: TRACKED_APPS.length,
});
});
// GET /api/health
routes.get("/api/health", (c) => c.json({ ok: true }));
// GET /api/stats — proxy to Umami stats API
routes.get("/api/stats", async (c) => {
const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000);
const endAt = c.req.query("endAt") || String(Date.now());
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) {
const data = await res.json();
return c.json({
...data as Record<string, unknown>,
trackedApps: TRACKED_APPS.length,
apps: TRACKED_APPS,
selfHosted: true,
dashboardUrl: UMAMI_URL,
});
}
} catch {}
// Fallback when Umami unreachable
return c.json({
trackedApps: TRACKED_APPS.length,
cookiesSet: 0,
scriptSize: "~2KB",
selfHosted: true,
dashboardUrl: UMAMI_URL,
apps: TRACKED_APPS,
});
});
// GET /api/active — proxy to Umami active visitors
routes.get("/api/active", async (c) => {
try {
const res = await fetch(
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`,
{ signal: AbortSignal.timeout(5000) }
);
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ x: 0 });
});
// GET /collect.js — proxy Umami tracker script
routes.get("/collect.js", async (c) => {
try {
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
if (res.ok) {
const script = await res.text();
return new Response(script, {
headers: {
"Content-Type": "application/javascript",
"Cache-Control": "public, max-age=3600",
},
});
}
} catch {}
return new Response("/* umami unavailable */", {
headers: { "Content-Type": "application/javascript" },
});
});
// POST /api/collect — proxy Umami event collection
routes.post("/api/collect", async (c) => {
try {
const body = await c.req.text();
const res = await fetch(`${UMAMI_URL}/api/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal: AbortSignal.timeout(5000),
});
if (res.ok) return c.json(await res.json());
} catch {}
return c.json({ ok: true });
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${space} — Data | rSpace`,
moduleId: "rdata",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-analytics-view space="${space}"></folk-analytics-view>`,
scripts: `<script type="module" src="/modules/data/folk-analytics-view.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/data/data.css">`,
}));
});
export const dataModule: RSpaceModule = {
id: "rdata",
name: "rData",
icon: "\u{1F4CA}",
description: "Privacy-first analytics for the r* ecosystem",
routes,
standaloneDomain: "rdata.online",
feeds: [
{
id: "analytics",
name: "Analytics Stream",
kind: "attention",
description: "Page views, active visitors, and engagement metrics across rApps",
filterable: true,
},
{
id: "active-users",
name: "Active Users",
kind: "attention",
description: "Real-time active visitor counts",
},
],
acceptsFeeds: ["data", "economic"],
};

View File

@ -0,0 +1,6 @@
/* Files module — dark theme */
folk-file-browser {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -0,0 +1,389 @@
/**
* <folk-file-browser> file browsing, upload, share links, and memory cards.
*
* Attributes:
* space="slug" shared space to browse (default: "default")
*/
class FolkFileBrowser extends HTMLElement {
private shadow: ShadowRoot;
private space = "default";
private files: any[] = [];
private cards: any[] = [];
private tab: "files" | "cards" = "files";
private loading = false;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "default";
this.render();
this.loadFiles();
this.loadCards();
}
private async loadFiles() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files?space=${encodeURIComponent(this.space)}`);
const data = await res.json();
this.files = data.files || [];
} catch (e) {
console.error("[FileBrowser] Error loading files:", e);
}
this.loading = false;
this.render();
}
private async loadCards() {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/cards?space=${encodeURIComponent(this.space)}`);
const data = await res.json();
this.cards = data.cards || [];
} catch {
this.cards = [];
}
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/files/);
return match ? `/${match[1]}/files` : "";
}
private formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
private formatDate(d: string): string {
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
}
private mimeIcon(mime: string): string {
if (mime?.startsWith("image/")) return "\uD83D\uDDBC\uFE0F";
if (mime?.startsWith("video/")) return "\uD83C\uDFA5";
if (mime?.startsWith("audio/")) return "\uD83C\uDFB5";
if (mime?.includes("pdf")) return "\uD83D\uDCC4";
if (mime?.includes("zip") || mime?.includes("tar") || mime?.includes("gz")) return "\uD83D\uDCE6";
if (mime?.includes("text") || mime?.includes("json") || mime?.includes("xml")) return "\uD83D\uDCDD";
return "\uD83D\uDCC1";
}
private cardTypeIcon(type: string): string {
const icons: Record<string, string> = {
note: "\uD83D\uDCDD",
idea: "\uD83D\uDCA1",
task: "\u2705",
reference: "\uD83D\uDD17",
quote: "\uD83D\uDCAC",
};
return icons[type] || "\uD83D\uDCDD";
}
private async handleUpload(e: Event) {
e.preventDefault();
const form = this.shadow.querySelector("#upload-form") as HTMLFormElement;
if (!form) return;
const fileInput = form.querySelector('input[type="file"]') as HTMLInputElement;
if (!fileInput?.files?.length) return;
const formData = new FormData();
formData.append("file", fileInput.files[0]);
formData.append("space", this.space);
const titleInput = form.querySelector('input[name="title"]') as HTMLInputElement;
if (titleInput?.value) formData.append("title", titleInput.value);
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files`, { method: "POST", body: formData });
if (res.ok) {
form.reset();
this.loadFiles();
} else {
const err = await res.json();
alert(`Upload failed: ${err.error || "Unknown error"}`);
}
} catch (e) {
alert("Upload failed — network error");
}
}
private async handleDelete(fileId: string) {
if (!confirm("Delete this file?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/files/${fileId}`, { method: "DELETE" });
this.loadFiles();
} catch {}
}
private async handleShare(fileId: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/files/${fileId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expires_in_hours: 72 }),
});
const data = await res.json();
if (data.share?.url) {
const fullUrl = `${window.location.origin}${this.getApiBase()}${data.share.url}`;
await navigator.clipboard.writeText(fullUrl).catch(() => {});
alert(`Share link copied!\n${fullUrl}\nExpires in 72 hours.`);
}
} catch {
alert("Failed to create share link");
}
}
private async handleCreateCard(e: Event) {
e.preventDefault();
const form = this.shadow.querySelector("#card-form") as HTMLFormElement;
if (!form) return;
const title = (form.querySelector('input[name="card-title"]') as HTMLInputElement)?.value;
const body = (form.querySelector('textarea[name="card-body"]') as HTMLTextAreaElement)?.value;
const cardType = (form.querySelector('select[name="card-type"]') as HTMLSelectElement)?.value;
if (!title) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/cards`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, body, card_type: cardType, shared_space: this.space }),
});
if (res.ok) {
form.reset();
this.loadCards();
}
} catch {}
}
private async handleDeleteCard(cardId: string) {
if (!confirm("Delete this card?")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/cards/${cardId}`, { method: "DELETE" });
this.loadCards();
} catch {}
}
private render() {
const filesActive = this.tab === "files";
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
.tabs { display: flex; gap: 4px; margin-bottom: 20px; }
.tab-btn {
padding: 8px 20px; border: 1px solid #444; border-radius: 6px 6px 0 0;
background: ${filesActive ? "#1a1a2e" : "#2a2a3e"}; color: #e0e0e0;
cursor: pointer; font-size: 14px; border-bottom: ${filesActive ? "2px solid #64b5f6" : "none"};
}
.tab-btn:last-child {
background: ${!filesActive ? "#1a1a2e" : "#2a2a3e"};
border-bottom: ${!filesActive ? "2px solid #64b5f6" : "none"};
}
.upload-section {
background: #1e1e2e; border: 1px dashed #555; border-radius: 8px;
padding: 16px; margin-bottom: 20px;
}
.upload-section h3 { margin: 0 0 12px; font-size: 14px; color: #aaa; }
.upload-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
input[type="file"] { color: #ccc; font-size: 13px; }
input[type="text"], select, textarea {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 6px 10px; border-radius: 4px; font-size: 13px;
}
textarea { width: 100%; min-height: 60px; resize: vertical; }
button {
padding: 6px 14px; border-radius: 4px; border: 1px solid #555;
background: #2a4a7a; color: #e0e0e0; cursor: pointer; font-size: 13px;
}
button:hover { background: #3a5a9a; }
button.danger { background: #7a2a2a; }
button.danger:hover { background: #9a3a3a; }
.file-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 12px;
}
.file-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px; transition: border-color 0.2s;
}
.file-card:hover { border-color: #64b5f6; }
.file-icon { font-size: 28px; margin-bottom: 8px; }
.file-name {
font-size: 14px; font-weight: 500; margin-bottom: 4px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-meta { font-size: 12px; color: #888; margin-bottom: 8px; }
.file-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.file-actions button { padding: 3px 8px; font-size: 11px; }
.card-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.memory-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 14px;
}
.memory-card:hover { border-color: #81c784; }
.card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px; }
.card-title { font-size: 14px; font-weight: 500; }
.card-type { font-size: 11px; background: #2a2a3e; padding: 2px 6px; border-radius: 3px; color: #aaa; }
.card-body { font-size: 13px; color: #aaa; white-space: pre-wrap; word-break: break-word; }
.empty { text-align: center; color: #666; padding: 40px 20px; font-size: 14px; }
.loading { text-align: center; color: #888; padding: 40px; }
.card-form { margin-bottom: 20px; display: flex; flex-direction: column; gap: 8px; }
.card-form-row { display: flex; gap: 8px; }
</style>
<div class="tabs">
<div class="tab-btn" data-tab="files">\uD83D\uDCC1 Files</div>
<div class="tab-btn" data-tab="cards">\uD83C\uDFB4 Memory Cards</div>
</div>
${filesActive ? this.renderFilesTab() : this.renderCardsTab()}
`;
this.shadow.querySelectorAll(".tab-btn").forEach((btn) => {
btn.addEventListener("click", () => {
this.tab = (btn as HTMLElement).dataset.tab as "files" | "cards";
this.render();
});
});
const uploadForm = this.shadow.querySelector("#upload-form");
if (uploadForm) uploadForm.addEventListener("submit", (e) => this.handleUpload(e));
const cardForm = this.shadow.querySelector("#card-form");
if (cardForm) cardForm.addEventListener("submit", (e) => this.handleCreateCard(e));
this.shadow.querySelectorAll("[data-action]").forEach((btn) => {
const action = (btn as HTMLElement).dataset.action!;
const id = (btn as HTMLElement).dataset.id!;
btn.addEventListener("click", () => {
if (action === "delete") this.handleDelete(id);
else if (action === "share") this.handleShare(id);
else if (action === "delete-card") this.handleDeleteCard(id);
else if (action === "download") {
const base = this.getApiBase();
window.open(`${base}/api/files/${id}/download`, "_blank");
}
});
});
}
private renderFilesTab(): string {
return `
<div class="upload-section">
<h3>Upload File</h3>
<form id="upload-form">
<div class="upload-row">
<input type="file" name="file" required>
<input type="text" name="title" placeholder="Title (optional)" style="flex:1;min-width:120px">
<button type="submit">Upload</button>
</div>
</form>
</div>
${this.loading ? '<div class="loading">Loading files...</div>' : ""}
${!this.loading && this.files.length === 0 ? '<div class="empty">No files yet. Upload one above.</div>' : ""}
${
!this.loading && this.files.length > 0
? `<div class="file-grid">
${this.files
.map(
(f) => `
<div class="file-card">
<div class="file-icon">${this.mimeIcon(f.mime_type)}</div>
<div class="file-name" title="${this.esc(f.original_filename)}">${this.esc(f.title || f.original_filename)}</div>
<div class="file-meta">${this.formatSize(f.file_size)} &middot; ${this.formatDate(f.created_at)}</div>
<div class="file-actions">
<button data-action="download" data-id="${f.id}">Download</button>
<button data-action="share" data-id="${f.id}">Share</button>
<button class="danger" data-action="delete" data-id="${f.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private renderCardsTab(): string {
return `
<div class="upload-section">
<h3>New Memory Card</h3>
<form id="card-form" class="card-form">
<div class="card-form-row">
<input type="text" name="card-title" placeholder="Title" required style="flex:1">
<select name="card-type">
<option value="note">Note</option>
<option value="idea">Idea</option>
<option value="task">Task</option>
<option value="reference">Reference</option>
<option value="quote">Quote</option>
</select>
<button type="submit">Add</button>
</div>
<textarea name="card-body" placeholder="Body (optional)"></textarea>
</form>
</div>
${this.cards.length === 0 ? '<div class="empty">No memory cards yet.</div>' : ""}
${
this.cards.length > 0
? `<div class="card-grid">
${this.cards
.map(
(c) => `
<div class="memory-card">
<div class="card-header">
<span class="card-title">${this.cardTypeIcon(c.card_type)} ${this.esc(c.title)}</span>
<span class="card-type">${c.card_type}</span>
</div>
${c.body ? `<div class="card-body">${this.esc(c.body)}</div>` : ""}
<div class="file-actions" style="margin-top:8px">
<button class="danger" data-action="delete-card" data-id="${c.id}">Delete</button>
</div>
</div>
`,
)
.join("")}
</div>`
: ""
}
`;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-file-browser", FolkFileBrowser);

View File

@ -0,0 +1,74 @@
-- rFiles schema — file sharing, memory cards
-- Inside rSpace shared DB, schema: rfiles
CREATE TABLE IF NOT EXISTS rfiles.media_files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
original_filename VARCHAR(500) NOT NULL,
title VARCHAR(500),
description TEXT,
mime_type VARCHAR(200),
file_size BIGINT DEFAULT 0,
file_hash VARCHAR(64),
storage_path TEXT NOT NULL,
tags JSONB DEFAULT '[]',
is_processed BOOLEAN DEFAULT FALSE,
processing_error TEXT,
uploaded_by TEXT,
shared_space TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_files_hash ON rfiles.media_files (file_hash);
CREATE INDEX IF NOT EXISTS idx_files_mime ON rfiles.media_files (mime_type);
CREATE INDEX IF NOT EXISTS idx_files_space ON rfiles.media_files (shared_space);
CREATE INDEX IF NOT EXISTS idx_files_created ON rfiles.media_files (created_at DESC);
CREATE TABLE IF NOT EXISTS rfiles.public_shares (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
token VARCHAR(48) NOT NULL UNIQUE,
media_file_id UUID NOT NULL REFERENCES rfiles.media_files(id) ON DELETE CASCADE,
created_by TEXT,
expires_at TIMESTAMPTZ,
max_downloads INTEGER,
download_count INTEGER DEFAULT 0,
is_password_protected BOOLEAN DEFAULT FALSE,
password_hash TEXT,
note VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shares_token ON rfiles.public_shares (token);
CREATE INDEX IF NOT EXISTS idx_shares_active ON rfiles.public_shares (is_active) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_shares_expires ON rfiles.public_shares (expires_at);
CREATE TABLE IF NOT EXISTS rfiles.memory_cards (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
shared_space TEXT NOT NULL,
title VARCHAR(500) NOT NULL,
body TEXT,
card_type VARCHAR(20) DEFAULT 'note' CHECK (card_type IN ('note', 'idea', 'task', 'reference', 'quote')),
tags JSONB DEFAULT '[]',
position INTEGER DEFAULT 0,
created_by TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cards_space ON rfiles.memory_cards (shared_space);
CREATE INDEX IF NOT EXISTS idx_cards_type ON rfiles.memory_cards (card_type);
CREATE INDEX IF NOT EXISTS idx_cards_position ON rfiles.memory_cards (position);
CREATE TABLE IF NOT EXISTS rfiles.access_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
media_file_id UUID REFERENCES rfiles.media_files(id) ON DELETE CASCADE,
share_id UUID REFERENCES rfiles.public_shares(id) ON DELETE SET NULL,
accessed_at TIMESTAMPTZ DEFAULT NOW(),
ip_address INET,
user_agent VARCHAR(500),
access_type VARCHAR(20) DEFAULT 'download' CHECK (access_type IN ('download', 'view', 'share_created', 'share_revoked'))
);
CREATE INDEX IF NOT EXISTS idx_logs_accessed ON rfiles.access_logs (accessed_at DESC);
CREATE INDEX IF NOT EXISTS idx_logs_type ON rfiles.access_logs (access_type);

388
modules/files/mod.ts Normal file
View File

@ -0,0 +1,388 @@
/**
* Files module file sharing, public share links, memory cards.
* Ported from rfiles-online (Django Bun/Hono).
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mkdir, writeFile, unlink } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono();
const FILES_DIR = process.env.FILES_DIR || "/data/files";
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
// ── DB initialization ──
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Files] DB schema initialized");
} catch (e: any) {
console.error("[Files] DB init error:", e.message);
}
}
initDB();
// ── Cleanup timers (replace Celery) ──
// Deactivate expired shares every hour
setInterval(async () => {
try {
const result = await sql.unsafe(
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE is_active = TRUE AND expires_at IS NOT NULL AND expires_at < NOW()"
);
if ((result as any).count > 0) console.log(`[Files] Deactivated ${(result as any).count} expired shares`);
} catch (e: any) { console.error("[Files] Cleanup error:", e.message); }
}, 3600_000);
// Delete access logs older than 90 days, daily
setInterval(async () => {
try {
await sql.unsafe("DELETE FROM rfiles.access_logs WHERE accessed_at < NOW() - INTERVAL '90 days'");
} catch (e: any) { console.error("[Files] Log cleanup error:", e.message); }
}, 86400_000);
// ── Helpers ──
function generateToken(): string {
return randomBytes(24).toString("base64url");
}
async function hashPassword(pw: string): Promise<string> {
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(pw + "rfiles-salt");
return hasher.digest("hex");
}
async function computeFileHash(buffer: ArrayBuffer): Promise<string> {
const hash = createHash("sha256");
hash.update(Buffer.from(buffer));
return hash.digest("hex");
}
// ── File upload ──
routes.post("/api/files", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const formData = await c.req.formData();
const file = formData.get("file") as File | null;
if (!file) return c.json({ error: "file is required" }, 400);
const space = c.req.param("space") || formData.get("space")?.toString() || "default";
const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, "");
const description = formData.get("description")?.toString() || "";
const tags = formData.get("tags")?.toString() || "[]";
const uploadedBy = claims.sub;
const buffer = await file.arrayBuffer();
const fileHash = await computeFileHash(buffer);
const now = new Date();
const datePath = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, "0")}/${String(now.getDate()).padStart(2, "0")}`;
const fileId = crypto.randomUUID();
const storagePath = `uploads/${datePath}/${fileId}/${file.name}`;
const fullPath = resolve(FILES_DIR, storagePath);
await mkdir(resolve(fullPath, ".."), { recursive: true });
await writeFile(fullPath, Buffer.from(buffer));
const [row] = await sql.unsafe(
`INSERT INTO rfiles.media_files (original_filename, title, description, mime_type, file_size, file_hash, storage_path, tags, uploaded_by, shared_space)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9, $10) RETURNING *`,
[file.name, title, description, file.type || "application/octet-stream", file.size, fileHash, storagePath, tags, uploadedBy, space]
);
return c.json({ file: row }, 201);
});
// ── File listing ──
routes.get("/api/files", async (c) => {
const space = c.req.param("space") || c.req.query("space") || "default";
const mimeType = c.req.query("mime_type");
const limit = Math.min(Number(c.req.query("limit")) || 50, 200);
const offset = Number(c.req.query("offset")) || 0;
let query = "SELECT * FROM rfiles.media_files WHERE shared_space = $1";
const params: any[] = [space];
let paramIdx = 2;
if (mimeType) {
query += ` AND mime_type LIKE $${paramIdx}`;
params.push(`${mimeType}%`);
paramIdx++;
}
query += ` ORDER BY created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`;
params.push(limit, offset);
const rows = await sql.unsafe(query, params);
const [{ count }] = await sql.unsafe(
"SELECT COUNT(*) as count FROM rfiles.media_files WHERE shared_space = $1",
[space]
);
return c.json({ files: rows, total: Number(count), limit, offset });
});
// ── File download ──
routes.get("/api/files/:id/download", async (c) => {
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
if (!file) return c.json({ error: "File not found" }, 404);
const fullPath = resolve(FILES_DIR, file.storage_path);
const bunFile = Bun.file(fullPath);
if (!await bunFile.exists()) return c.json({ error: "File missing from storage" }, 404);
return new Response(bunFile, {
headers: {
"Content-Type": file.mime_type || "application/octet-stream",
"Content-Disposition": `attachment; filename="${file.original_filename}"`,
"Content-Length": String(file.file_size),
},
});
});
// ── File detail ──
routes.get("/api/files/:id", async (c) => {
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
if (!file) return c.json({ error: "File not found" }, 404);
return c.json({ file });
});
// ── File delete ──
routes.delete("/api/files/:id", async (c) => {
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
if (!file) return c.json({ error: "File not found" }, 404);
try { await unlink(resolve(FILES_DIR, file.storage_path)); } catch {}
await sql.unsafe("DELETE FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
return c.json({ message: "Deleted" });
});
// ── Create share link ──
routes.post("/api/files/:id/share", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
if (!file) return c.json({ error: "File not found" }, 404);
if (file.uploaded_by && file.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>();
const token = generateToken();
const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null;
const createdBy = claims.sub;
let passwordHash: string | null = null;
let isPasswordProtected = false;
if (body.password) {
passwordHash = await hashPassword(body.password);
isPasswordProtected = true;
}
const [share] = await sql.unsafe(
`INSERT INTO rfiles.public_shares (token, media_file_id, created_by, expires_at, max_downloads, is_password_protected, password_hash, note)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[token, file.id, createdBy, expiresAt, body.max_downloads || null, isPasswordProtected, passwordHash, body.note || null]
);
await sql.unsafe(
"INSERT INTO rfiles.access_logs (media_file_id, share_id, access_type) VALUES ($1, $2, 'share_created')",
[file.id, share.id]
);
return c.json({ share: { ...share, url: `/s/${token}` } }, 201);
});
// ── List shares for a file ──
routes.get("/api/files/:id/shares", async (c) => {
const rows = await sql.unsafe(
"SELECT * FROM rfiles.public_shares WHERE media_file_id = $1 ORDER BY created_at DESC",
[c.req.param("id")]
);
return c.json({ shares: rows });
});
// ── Revoke share ──
routes.post("/api/shares/:shareId/revoke", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const [share] = await sql.unsafe(
"SELECT s.*, f.uploaded_by FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.id = $1",
[c.req.param("shareId")]
);
if (!share) return c.json({ error: "Share not found" }, 404);
if (share.uploaded_by && share.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
const [revoked] = await sql.unsafe(
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
[c.req.param("shareId")]
);
return c.json({ message: "Revoked", share: revoked });
});
// ── Public share download ──
routes.get("/s/:token", async (c) => {
const [share] = await sql.unsafe(
`SELECT s.*, f.storage_path, f.mime_type, f.original_filename, f.file_size
FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id
WHERE s.token = $1`,
[c.req.param("token")]
);
if (!share) return c.json({ error: "Share not found" }, 404);
if (!share.is_active) return c.json({ error: "Share has been revoked" }, 410);
if (share.expires_at && new Date(share.expires_at) < new Date()) return c.json({ error: "Share has expired" }, 410);
if (share.max_downloads && share.download_count >= share.max_downloads) return c.json({ error: "Download limit reached" }, 410);
if (share.is_password_protected) {
const pw = c.req.query("password");
if (!pw) return c.json({ error: "Password required", is_password_protected: true }, 401);
const hash = await hashPassword(pw);
if (hash !== share.password_hash) return c.json({ error: "Invalid password" }, 401);
}
await sql.unsafe("UPDATE rfiles.public_shares SET download_count = download_count + 1 WHERE id = $1", [share.id]);
const ip = c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() || c.req.header("X-Real-IP") || null;
const ua = c.req.header("User-Agent") || "";
await sql.unsafe(
"INSERT INTO rfiles.access_logs (media_file_id, share_id, ip_address, user_agent, access_type) VALUES ($1, $2, $3, $4, 'download')",
[share.media_file_id, share.id, ip, ua.slice(0, 500)]
);
const fullPath = resolve(FILES_DIR, share.storage_path);
const bunFile = Bun.file(fullPath);
if (!await bunFile.exists()) return c.json({ error: "File missing" }, 404);
return new Response(bunFile, {
headers: {
"Content-Type": share.mime_type || "application/octet-stream",
"Content-Disposition": `attachment; filename="${share.original_filename}"`,
"Content-Length": String(share.file_size),
},
});
});
// ── Share info (public) ──
routes.get("/s/:token/info", async (c) => {
const [share] = await sql.unsafe(
`SELECT s.is_password_protected, s.is_active, s.expires_at, s.max_downloads, s.download_count, s.note,
f.original_filename, f.mime_type, f.file_size
FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id
WHERE s.token = $1`,
[c.req.param("token")]
);
if (!share) return c.json({ error: "Share not found" }, 404);
const isValid = share.is_active &&
(!share.expires_at || new Date(share.expires_at) > new Date()) &&
(!share.max_downloads || share.download_count < share.max_downloads);
return c.json({
is_password_protected: share.is_password_protected,
is_valid: isValid,
expires_at: share.expires_at,
downloads_remaining: share.max_downloads ? share.max_downloads - share.download_count : null,
file_info: { filename: share.original_filename, mime_type: share.mime_type, size: share.file_size },
note: share.note,
});
});
// ── Memory Cards CRUD ──
routes.post("/api/cards", async (c) => {
const authToken = extractToken(c.req.raw.headers);
if (!authToken) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>();
const space = c.req.param("space") || body.shared_space || "default";
const createdBy = claims.sub;
const [card] = await sql.unsafe(
`INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by)
VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`,
[space, body.title, body.body || "", body.card_type || "note", JSON.stringify(body.tags || []), createdBy]
);
return c.json({ card }, 201);
});
routes.get("/api/cards", async (c) => {
const space = c.req.param("space") || c.req.query("space") || "default";
const cardType = c.req.query("type");
const limit = Math.min(Number(c.req.query("limit")) || 50, 200);
let query = "SELECT * FROM rfiles.memory_cards WHERE shared_space = $1";
const params: any[] = [space];
if (cardType) { query += " AND card_type = $2"; params.push(cardType); }
query += " ORDER BY position, created_at DESC LIMIT $" + (params.length + 1);
params.push(limit);
const rows = await sql.unsafe(query, params);
return c.json({ cards: rows, total: rows.length });
});
routes.patch("/api/cards/:id", async (c) => {
const body = await c.req.json<{ title?: string; body?: string; card_type?: string; tags?: string[]; position?: number }>();
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
if (body.title !== undefined) { sets.push(`title = $${idx}`); params.push(body.title); idx++; }
if (body.body !== undefined) { sets.push(`body = $${idx}`); params.push(body.body); idx++; }
if (body.card_type !== undefined) { sets.push(`card_type = $${idx}`); params.push(body.card_type); idx++; }
if (body.tags !== undefined) { sets.push(`tags = $${idx}::jsonb`); params.push(JSON.stringify(body.tags)); idx++; }
if (body.position !== undefined) { sets.push(`position = $${idx}`); params.push(body.position); idx++; }
if (sets.length === 0) return c.json({ error: "No fields to update" }, 400);
sets.push(`updated_at = NOW()`);
params.push(c.req.param("id"));
const [card] = await sql.unsafe(
`UPDATE rfiles.memory_cards SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (!card) return c.json({ error: "Card not found" }, 404);
return c.json({ card });
});
routes.delete("/api/cards/:id", async (c) => {
const [card] = await sql.unsafe("DELETE FROM rfiles.memory_cards WHERE id = $1 RETURNING id", [c.req.param("id")]);
if (!card) return c.json({ error: "Card not found" }, 404);
return c.json({ message: "Deleted" });
});
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Files | rSpace`,
moduleId: "rfiles",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-file-browser space="${spaceSlug}"></folk-file-browser>`,
scripts: `<script type="module" src="/modules/files/folk-file-browser.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/files/files.css">`,
}));
});
export const filesModule: RSpaceModule = {
id: "rfiles",
name: "rFiles",
icon: "\uD83D\uDCC1",
description: "File sharing, share links, and memory cards",
routes,
standaloneDomain: "rfiles.online",
};

View File

@ -0,0 +1,425 @@
/**
* <folk-forum-dashboard> Discourse instance provisioner dashboard.
*
* Lists user's forum instances, shows provisioning status, and allows
* creating new instances.
*/
class FolkForumDashboard extends HTMLElement {
private shadow: ShadowRoot;
private instances: any[] = [];
private selectedInstance: any = null;
private selectedLogs: any[] = [];
private view: "list" | "detail" | "create" = "list";
private loading = false;
private pollTimer: number | null = null;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
this.loadInstances();
}
disconnectedCallback() {
if (this.pollTimer) clearInterval(this.pollTimer);
}
private getApiBase(): string {
const path = window.location.pathname;
const match = path.match(/^\/([^/]+)\/forum/);
return match ? `/${match[1]}/forum` : "";
}
private getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("encryptid_session");
if (token) {
try {
const parsed = JSON.parse(token);
return { "X-User-DID": parsed.did || "" };
} catch {}
}
return {};
}
private async loadInstances() {
this.loading = true;
this.render();
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/instances`, { headers: this.getAuthHeaders() });
if (res.ok) {
const data = await res.json();
this.instances = data.instances || [];
}
} catch (e) {
console.error("[ForumDashboard] Error:", e);
}
this.loading = false;
this.render();
}
private async loadInstanceDetail(id: string) {
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/instances/${id}`, { headers: this.getAuthHeaders() });
if (res.ok) {
const data = await res.json();
this.selectedInstance = data.instance;
this.selectedLogs = data.logs || [];
this.view = "detail";
this.render();
// Poll if provisioning
const active = ["pending", "provisioning", "installing", "configuring"];
if (active.includes(this.selectedInstance.status)) {
if (this.pollTimer) clearInterval(this.pollTimer);
this.pollTimer = setInterval(() => this.loadInstanceDetail(id), 5000) as any;
} else {
if (this.pollTimer) clearInterval(this.pollTimer);
}
}
} catch {}
}
private async handleCreate(e: Event) {
e.preventDefault();
const form = this.shadow.querySelector("#create-form") as HTMLFormElement;
if (!form) return;
const name = (form.querySelector('[name="name"]') as HTMLInputElement)?.value;
const subdomain = (form.querySelector('[name="subdomain"]') as HTMLInputElement)?.value;
const adminEmail = (form.querySelector('[name="admin_email"]') as HTMLInputElement)?.value;
const region = (form.querySelector('[name="region"]') as HTMLSelectElement)?.value;
const size = (form.querySelector('[name="size"]') as HTMLSelectElement)?.value;
if (!name || !subdomain || !adminEmail) return;
try {
const base = this.getApiBase();
const res = await fetch(`${base}/api/instances`, {
method: "POST",
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
body: JSON.stringify({ name, subdomain, admin_email: adminEmail, region, size }),
});
if (res.ok) {
const data = await res.json();
this.view = "detail";
this.loadInstanceDetail(data.instance.id);
} else {
const err = await res.json();
alert(err.error || "Failed to create instance");
}
} catch {
alert("Network error");
}
}
private async handleDestroy(id: string) {
if (!confirm("Are you sure you want to destroy this forum instance? This cannot be undone.")) return;
try {
const base = this.getApiBase();
await fetch(`${base}/api/instances/${id}`, {
method: "DELETE",
headers: this.getAuthHeaders(),
});
this.view = "list";
this.loadInstances();
} catch {}
}
private statusBadge(status: string): string {
const colors: Record<string, string> = {
pending: "#ffa726",
provisioning: "#42a5f5",
installing: "#42a5f5",
configuring: "#42a5f5",
active: "#66bb6a",
error: "#ef5350",
destroying: "#ffa726",
destroyed: "#888",
};
const color = colors[status] || "#888";
const pulse = ["provisioning", "installing", "configuring"].includes(status)
? "animation: pulse 1.5s ease-in-out infinite;"
: "";
return `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;background:${color}22;color:${color};border:1px solid ${color}44;${pulse}">${status}</span>`;
}
private logStepIcon(status: string): string {
if (status === "success") return "\u2705";
if (status === "error") return "\u274C";
if (status === "running") return "\u23F3";
return "\u23ED\uFE0F";
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
* { box-sizing: border-box; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.rapp-nav { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; min-height: 36px; }
.rapp-nav__back { padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1); background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px; }
.rapp-nav__back:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.rapp-nav__title { font-size: 15px; font-weight: 600; flex: 1; color: #e2e8f0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #6366f1; }
button {
padding: 6px 14px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.1);
background: transparent; color: #94a3b8; cursor: pointer; font-size: 13px;
}
button:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
button.danger { background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.3); color: #ef4444; }
button.danger:hover { background: rgba(239,68,68,0.25); }
input, select {
background: #2a2a3e; border: 1px solid #444; color: #e0e0e0;
padding: 8px 12px; border-radius: 4px; font-size: 13px; width: 100%;
}
label { display: block; font-size: 12px; color: #aaa; margin-bottom: 4px; }
.form-group { margin-bottom: 14px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.instance-list { display: flex; flex-direction: column; gap: 10px; }
.instance-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px;
padding: 16px; cursor: pointer; transition: border-color 0.2s;
}
.instance-card:hover { border-color: #64b5f6; }
.instance-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.instance-name { font-size: 16px; font-weight: 600; }
.instance-meta { font-size: 12px; color: #888; }
.detail-panel { background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 20px; }
.detail-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 16px; }
.detail-title { font-size: 20px; font-weight: 600; }
.detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px; }
.detail-item label { font-size: 11px; color: #888; text-transform: uppercase; }
.detail-item .value { font-size: 14px; margin-top: 2px; }
.logs-section h3 { font-size: 14px; color: #aaa; margin: 0 0 12px; }
.log-entry { display: flex; gap: 10px; align-items: start; padding: 8px 0; border-bottom: 1px solid #2a2a3e; }
.log-icon { font-size: 16px; flex-shrink: 0; }
.log-step { font-size: 13px; font-weight: 500; }
.log-msg { font-size: 12px; color: #888; margin-top: 2px; }
.empty { text-align: center; color: #666; padding: 40px 20px; }
.loading { text-align: center; color: #888; padding: 40px; }
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
.price-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 8px; padding: 14px;
text-align: center; cursor: pointer; transition: border-color 0.2s;
}
.price-card:hover, .price-card.selected { border-color: #64b5f6; }
.price-name { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
.price-cost { font-size: 18px; color: #64b5f6; font-weight: 700; }
.price-specs { font-size: 11px; color: #888; margin-top: 4px; }
</style>
${this.view === "list" ? this.renderList() : ""}
${this.view === "detail" ? this.renderDetail() : ""}
${this.view === "create" ? this.renderCreate() : ""}
`;
this.attachEvents();
}
private renderList(): string {
return `
<div class="rapp-nav">
<span class="rapp-nav__title">Forum Instances</span>
<button class="rapp-nav__btn" data-action="show-create">+ New Forum</button>
</div>
${this.loading ? '<div class="loading">Loading...</div>' : ""}
${!this.loading && this.instances.length === 0 ? '<div class="empty">No forum instances yet. Deploy your first Discourse forum!</div>' : ""}
<div class="instance-list">
${this.instances.map((inst) => `
<div class="instance-card" data-action="detail" data-id="${inst.id}">
<div class="instance-header">
<span class="instance-name">${this.esc(inst.name)}</span>
${this.statusBadge(inst.status)}
</div>
<div class="instance-meta">
${inst.domain} &middot; ${inst.region} &middot; ${inst.size}
${inst.vps_ip ? ` &middot; ${inst.vps_ip}` : ""}
</div>
</div>
`).join("")}
</div>
`;
}
private renderDetail(): string {
const inst = this.selectedInstance;
if (!inst) return "";
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-action="back">\u2190 Forums</button>
<span class="rapp-nav__title">${this.esc(inst.name)}</span>
${inst.status !== "destroyed" ? `<button class="danger" data-action="destroy" data-id="${inst.id}">Destroy</button>` : ""}
</div>
<div class="detail-panel">
<div class="detail-header">
<div>
<div class="detail-title">${this.esc(inst.name)}</div>
<div style="margin-top:4px">${this.statusBadge(inst.status)}</div>
</div>
${inst.status === "active" ? `<a href="https://${inst.domain}" target="_blank" style="color:#64b5f6;font-size:13px">\u2197 Open Forum</a>` : ""}
</div>
${inst.error_message ? `<div style="background:#7a2a2a33;border:1px solid #7a2a2a;padding:10px;border-radius:6px;margin-bottom:16px;font-size:13px;color:#ef5350">${this.esc(inst.error_message)}</div>` : ""}
<div class="detail-grid">
<div class="detail-item"><label>Domain</label><div class="value">${inst.domain}</div></div>
<div class="detail-item"><label>IP Address</label><div class="value">${inst.vps_ip || "—"}</div></div>
<div class="detail-item"><label>Region</label><div class="value">${inst.region}</div></div>
<div class="detail-item"><label>Server Size</label><div class="value">${inst.size}</div></div>
<div class="detail-item"><label>Admin Email</label><div class="value">${inst.admin_email || "—"}</div></div>
<div class="detail-item"><label>SSL</label><div class="value">${inst.ssl_provisioned ? "\u2705 Active" : "\u23F3 Pending"}</div></div>
</div>
<div class="logs-section">
<h3>Provision Log</h3>
${this.selectedLogs.length === 0 ? '<div style="color:#666;font-size:13px">No logs yet</div>' : ""}
${this.selectedLogs.map((log) => `
<div class="log-entry">
<span class="log-icon">${this.logStepIcon(log.status)}</span>
<div>
<div class="log-step">${this.formatStep(log.step)}</div>
<div class="log-msg">${this.esc(log.message || "")}</div>
</div>
</div>
`).join("")}
</div>
</div>
`;
}
private renderCreate(): string {
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-action="back">\u2190 Forums</button>
<span class="rapp-nav__title">Deploy New Forum</span>
</div>
<div class="detail-panel">
<h2 style="margin:0 0 16px;font-size:18px">Deploy New Forum</h2>
<div class="pricing">
<div class="price-card selected" data-size="cx22">
<div class="price-name">Starter</div>
<div class="price-cost">\u20AC3.79/mo</div>
<div class="price-specs">2 vCPU &middot; 4 GB &middot; ~500 users</div>
</div>
<div class="price-card" data-size="cx32">
<div class="price-name">Standard</div>
<div class="price-cost">\u20AC6.80/mo</div>
<div class="price-specs">4 vCPU &middot; 8 GB &middot; ~2000 users</div>
</div>
<div class="price-card" data-size="cx42">
<div class="price-name">Performance</div>
<div class="price-cost">\u20AC13.80/mo</div>
<div class="price-specs">8 vCPU &middot; 16 GB &middot; ~10k users</div>
</div>
</div>
<form id="create-form">
<div class="form-row">
<div class="form-group">
<label>Forum Name</label>
<input name="name" placeholder="My Community" required>
</div>
<div class="form-group">
<label>Subdomain</label>
<div style="display:flex;align-items:center;gap:4px">
<input name="subdomain" placeholder="my-community" required style="flex:1">
<span style="font-size:12px;color:#888;white-space:nowrap">.rforum.online</span>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Admin Email</label>
<input name="admin_email" type="email" placeholder="admin@example.com" required>
</div>
<div class="form-group">
<label>Region</label>
<select name="region">
<option value="nbg1">Nuremberg (EU)</option>
<option value="fsn1">Falkenstein (EU)</option>
<option value="hel1">Helsinki (EU)</option>
<option value="ash">Ashburn (US East)</option>
<option value="hil">Hillsboro (US West)</option>
</select>
</div>
</div>
<input type="hidden" name="size" value="cx22">
<button type="submit" style="width:100%;padding:10px;font-size:14px;margin-top:8px">
Deploy Forum
</button>
</form>
</div>
`;
}
private attachEvents() {
this.shadow.querySelectorAll("[data-action]").forEach((el) => {
const action = (el as HTMLElement).dataset.action!;
const id = (el as HTMLElement).dataset.id;
el.addEventListener("click", () => {
if (action === "show-create") { this.view = "create"; this.render(); }
else if (action === "back") {
if (this.pollTimer) clearInterval(this.pollTimer);
this.view = "list"; this.loadInstances();
}
else if (action === "detail" && id) { this.loadInstanceDetail(id); }
else if (action === "destroy" && id) { this.handleDestroy(id); }
});
});
this.shadow.querySelectorAll(".price-card").forEach((card) => {
card.addEventListener("click", () => {
this.shadow.querySelectorAll(".price-card").forEach((c) => c.classList.remove("selected"));
card.classList.add("selected");
const sizeInput = this.shadow.querySelector('[name="size"]') as HTMLInputElement;
if (sizeInput) sizeInput.value = (card as HTMLElement).dataset.size || "cx22";
});
});
const form = this.shadow.querySelector("#create-form");
if (form) form.addEventListener("submit", (e) => this.handleCreate(e));
}
private formatStep(step: string): string {
const labels: Record<string, string> = {
create_vps: "Create Server",
wait_ready: "Wait for Boot",
configure_dns: "Configure DNS",
install_discourse: "Install Discourse",
verify_live: "Verify Live",
};
return labels[step] || step;
}
private esc(s: string): string {
const d = document.createElement("div");
d.textContent = s || "";
return d.innerHTML;
}
}
customElements.define("folk-forum-dashboard", FolkForumDashboard);

View File

@ -0,0 +1,6 @@
/* Forum module — dark theme */
folk-forum-dashboard {
display: block;
min-height: 400px;
padding: 20px;
}

View File

@ -0,0 +1,55 @@
-- rForum schema — Discourse cloud provisioning
-- Inside rSpace shared DB, schema: rforum
CREATE TABLE IF NOT EXISTS rforum.users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
did TEXT UNIQUE,
username TEXT,
email TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS rforum.instances (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES rforum.users(id),
name TEXT NOT NULL,
domain TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','provisioning','installing','configuring','active','error','destroying','destroyed')),
error_message TEXT,
discourse_version TEXT DEFAULT 'stable',
provider TEXT DEFAULT 'hetzner' CHECK (provider IN ('hetzner','digitalocean')),
vps_id TEXT,
vps_ip TEXT,
region TEXT DEFAULT 'nbg1',
size TEXT DEFAULT 'cx22',
admin_email TEXT,
smtp_config JSONB DEFAULT '{}',
dns_record_id TEXT,
ssl_provisioned BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
provisioned_at TIMESTAMPTZ,
destroyed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_instances_user ON rforum.instances (user_id);
CREATE INDEX IF NOT EXISTS idx_instances_status ON rforum.instances (status);
CREATE INDEX IF NOT EXISTS idx_instances_domain ON rforum.instances (domain);
CREATE TABLE IF NOT EXISTS rforum.provision_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
instance_id UUID NOT NULL REFERENCES rforum.instances(id) ON DELETE CASCADE,
step TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'running'
CHECK (status IN ('running','success','error','skipped')),
message TEXT,
metadata JSONB DEFAULT '{}',
started_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_logs_instance ON rforum.provision_logs (instance_id);
CREATE INDEX IF NOT EXISTS idx_logs_step ON rforum.provision_logs (step);

View File

@ -0,0 +1,81 @@
/**
* Cloud-init user data generator for Discourse instances.
*/
export interface DiscourseConfig {
hostname: string;
adminEmail: string;
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPassword?: string;
}
export function generateCloudInit(config: DiscourseConfig): string {
const smtpHost = config.smtpHost || "mail.rmail.online";
const smtpPort = config.smtpPort || 587;
const smtpUser = config.smtpUser || `noreply@rforum.online`;
const smtpPassword = config.smtpPassword || "";
return `#!/bin/bash
set -e
# Swap
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
# Install Docker
apt-get update
apt-get install -y git docker.io docker-compose
systemctl enable docker
systemctl start docker
# Clone Discourse
git clone https://github.com/discourse/discourse_docker.git /var/discourse
cd /var/discourse
# Write app.yml
cat > containers/app.yml << 'APPYML'
templates:
- "templates/postgres.template.yml"
- "templates/redis.template.yml"
- "templates/web.template.yml"
- "templates/web.ssl.template.yml"
- "templates/web.letsencrypt.ssl.template.yml"
expose:
- "80:80"
- "443:443"
params:
db_default_text_search_config: "pg_catalog.english"
env:
LANG: en_US.UTF-8
DISCOURSE_DEFAULT_LOCALE: en
DISCOURSE_HOSTNAME: '${config.hostname}'
DISCOURSE_DEVELOPER_EMAILS: '${config.adminEmail}'
DISCOURSE_SMTP_ADDRESS: '${smtpHost}'
DISCOURSE_SMTP_PORT: ${smtpPort}
DISCOURSE_SMTP_USER_NAME: '${smtpUser}'
DISCOURSE_SMTP_PASSWORD: '${smtpPassword}'
DISCOURSE_SMTP_ENABLE_START_TLS: true
LETSENCRYPT_ACCOUNT_EMAIL: '${config.adminEmail}'
volumes:
- volume:
host: /var/discourse/shared/standalone
guest: /shared
- volume:
host: /var/discourse/shared/standalone/log/var-log
guest: /var/log
APPYML
# Bootstrap and start
./launcher bootstrap app
./launcher start app
`;
}

53
modules/forum/lib/dns.ts Normal file
View File

@ -0,0 +1,53 @@
/**
* Cloudflare DNS management for forum subdomains.
*/
const CF_API = "https://api.cloudflare.com/client/v4";
function headers(): Record<string, string> {
const token = process.env.CLOUDFLARE_API_TOKEN;
if (!token) throw new Error("CLOUDFLARE_API_TOKEN not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
}
export async function createDNSRecord(
subdomain: string,
ip: string,
): Promise<{ recordId: string } | null> {
const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID;
if (!zoneId) throw new Error("CLOUDFLARE_FORUM_ZONE_ID not set");
const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, {
method: "POST",
headers: headers(),
body: JSON.stringify({
type: "A",
name: `${subdomain}.rforum.online`,
content: ip,
ttl: 300,
proxied: false,
}),
});
if (!res.ok) {
console.error("[Forum DNS] Failed to create record:", await res.text());
return null;
}
const data = await res.json();
return { recordId: data.result.id };
}
export async function deleteDNSRecord(recordId: string): Promise<boolean> {
const zoneId = process.env.CLOUDFLARE_FORUM_ZONE_ID;
if (!zoneId) return false;
const res = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${recordId}`, {
method: "DELETE",
headers: headers(),
});
return res.ok;
}

View File

@ -0,0 +1,80 @@
/**
* Hetzner Cloud API client for VPS provisioning.
*/
const HETZNER_API = "https://api.hetzner.cloud/v1";
function headers(): Record<string, string> {
const token = process.env.HETZNER_API_TOKEN;
if (!token) throw new Error("HETZNER_API_TOKEN not set");
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
}
export interface HetznerServer {
id: number;
name: string;
status: string;
public_net: {
ipv4: { ip: string };
ipv6: { ip: string };
};
server_type: { name: string };
datacenter: { name: string };
}
export async function createServer(opts: {
name: string;
serverType: string;
region: string;
userData: string;
}): Promise<{ serverId: string; ip: string }> {
const res = await fetch(`${HETZNER_API}/servers`, {
method: "POST",
headers: headers(),
body: JSON.stringify({
name: opts.name,
server_type: opts.serverType,
location: opts.region,
image: "ubuntu-22.04",
user_data: opts.userData,
start_after_create: true,
}),
});
if (!res.ok) {
const err = await res.text();
throw new Error(`Hetzner create failed: ${res.status} ${err}`);
}
const data = await res.json();
return {
serverId: String(data.server.id),
ip: data.server.public_net.ipv4.ip,
};
}
export async function getServer(serverId: string): Promise<HetznerServer | null> {
const res = await fetch(`${HETZNER_API}/servers/${serverId}`, { headers: headers() });
if (!res.ok) return null;
const data = await res.json();
return data.server;
}
export async function deleteServer(serverId: string): Promise<boolean> {
const res = await fetch(`${HETZNER_API}/servers/${serverId}`, {
method: "DELETE",
headers: headers(),
});
return res.ok;
}
export async function serverAction(serverId: string, action: "poweron" | "poweroff" | "reboot"): Promise<boolean> {
const res = await fetch(`${HETZNER_API}/servers/${serverId}/actions/${action}`, {
method: "POST",
headers: headers(),
});
return res.ok;
}

View File

@ -0,0 +1,173 @@
/**
* Forum instance provisioner async pipeline that creates a VPS,
* configures DNS, installs Discourse, and verifies it's live.
*/
import { sql } from "../../../shared/db/pool";
import { createServer, getServer, deleteServer } from "./hetzner";
import { createDNSRecord, deleteDNSRecord } from "./dns";
import { generateCloudInit, type DiscourseConfig } from "./cloud-init";
type StepStatus = "running" | "success" | "error" | "skipped";
async function logStep(
instanceId: string,
step: string,
status: StepStatus,
message: string,
metadata: Record<string, unknown> = {},
) {
if (status === "running") {
await sql.unsafe(
`INSERT INTO rforum.provision_logs (instance_id, step, status, message, metadata)
VALUES ($1, $2, $3, $4, $5::jsonb)`,
[instanceId, step, status, message, JSON.stringify(metadata)],
);
} else {
await sql.unsafe(
`UPDATE rforum.provision_logs SET status = $1, message = $2, metadata = $3::jsonb, completed_at = NOW()
WHERE instance_id = $4 AND step = $5 AND status = 'running'`,
[status, message, JSON.stringify(metadata), instanceId, step],
);
}
}
async function updateInstance(instanceId: string, fields: Record<string, unknown>) {
const sets: string[] = [];
const params: any[] = [];
let idx = 1;
for (const [key, val] of Object.entries(fields)) {
sets.push(`${key} = $${idx}`);
params.push(val);
idx++;
}
sets.push("updated_at = NOW()");
params.push(instanceId);
await sql.unsafe(`UPDATE rforum.instances SET ${sets.join(", ")} WHERE id = $${idx}`, params);
}
async function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export async function provisionInstance(instanceId: string) {
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
if (!instance) throw new Error("Instance not found");
await updateInstance(instanceId, { status: "provisioning" });
try {
// Step 1: Create VPS
await logStep(instanceId, "create_vps", "running", "Creating VPS...");
const config: DiscourseConfig = {
hostname: instance.domain,
adminEmail: instance.admin_email,
...(instance.smtp_config?.host ? {
smtpHost: instance.smtp_config.host,
smtpPort: instance.smtp_config.port,
smtpUser: instance.smtp_config.user,
smtpPassword: instance.smtp_config.password,
} : {}),
};
const userData = generateCloudInit(config);
const { serverId, ip } = await createServer({
name: `discourse-${instance.domain.replace(/\./g, "-")}`,
serverType: instance.size,
region: instance.region,
userData,
});
await updateInstance(instanceId, { vps_id: serverId, vps_ip: ip });
await logStep(instanceId, "create_vps", "success", `VPS created: ${ip}`, { serverId, ip });
// Step 2: Wait for boot
await logStep(instanceId, "wait_ready", "running", "Waiting for VPS to boot...");
let booted = false;
for (let i = 0; i < 60; i++) {
await sleep(5000);
const server = await getServer(serverId);
if (server?.status === "running") {
booted = true;
break;
}
}
if (!booted) {
await logStep(instanceId, "wait_ready", "error", "VPS failed to boot within 5 minutes");
await updateInstance(instanceId, { status: "error", error_message: "VPS boot timeout" });
return;
}
await logStep(instanceId, "wait_ready", "success", "VPS is running");
// Step 3: Configure DNS
await logStep(instanceId, "configure_dns", "running", "Configuring DNS...");
const subdomain = instance.domain.replace(".rforum.online", "");
const dns = await createDNSRecord(subdomain, ip);
if (dns) {
await updateInstance(instanceId, { dns_record_id: dns.recordId });
await logStep(instanceId, "configure_dns", "success", `DNS record created for ${instance.domain}`);
} else {
await logStep(instanceId, "configure_dns", "skipped", "DNS configuration skipped — configure manually");
}
// Step 4: Wait for Discourse install
await updateInstance(instanceId, { status: "installing" });
await logStep(instanceId, "install_discourse", "running", "Installing Discourse (this takes 10-15 minutes)...");
let installed = false;
for (let i = 0; i < 60; i++) {
await sleep(15000);
try {
const res = await fetch(`http://${ip}`, { redirect: "manual" });
if (res.status === 200 || res.status === 302) {
installed = true;
break;
}
} catch {}
}
if (!installed) {
await logStep(instanceId, "install_discourse", "error", "Discourse did not respond within 15 minutes");
await updateInstance(instanceId, { status: "error", error_message: "Discourse install timeout" });
return;
}
await logStep(instanceId, "install_discourse", "success", "Discourse is responding");
// Step 5: Verify live
await updateInstance(instanceId, { status: "configuring" });
await logStep(instanceId, "verify_live", "running", "Verifying Discourse is live...");
try {
const res = await fetch(`https://${instance.domain}`, { redirect: "manual" });
if (res.status === 200 || res.status === 302) {
await updateInstance(instanceId, {
status: "active",
ssl_provisioned: true,
provisioned_at: new Date().toISOString(),
});
await logStep(instanceId, "verify_live", "success", "Forum is live with SSL!");
} else {
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
await logStep(instanceId, "verify_live", "success", "Forum is live (SSL pending)");
}
} catch {
await updateInstance(instanceId, { status: "active", provisioned_at: new Date().toISOString() });
await logStep(instanceId, "verify_live", "success", "Forum provisioned (SSL may take a few minutes)");
}
} catch (e: any) {
console.error("[Forum] Provisioning error:", e);
await updateInstance(instanceId, { status: "error", error_message: e.message });
await logStep(instanceId, "unknown", "error", e.message);
}
}
export async function destroyInstance(instanceId: string) {
const [instance] = await sql.unsafe("SELECT * FROM rforum.instances WHERE id = $1", [instanceId]);
if (!instance) return;
await updateInstance(instanceId, { status: "destroying" });
if (instance.vps_id) {
await deleteServer(instance.vps_id);
}
if (instance.dns_record_id) {
await deleteDNSRecord(instance.dns_record_id);
}
await updateInstance(instanceId, { status: "destroyed", destroyed_at: new Date().toISOString() });
}

179
modules/forum/mod.ts Normal file
View File

@ -0,0 +1,179 @@
/**
* Forum module Discourse cloud provisioner.
* Deploy self-hosted Discourse forums on Hetzner VPS with Cloudflare DNS.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { sql } from "../../shared/db/pool";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { provisionInstance, destroyInstance } from "./lib/provisioner";
import type { RSpaceModule } from "../../shared/module";
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
const routes = new Hono();
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
// ── DB initialization ──
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Forum] DB schema initialized");
} catch (e: any) {
console.error("[Forum] DB init error:", e.message);
}
}
initDB();
// ── Helpers ──
async function getOrCreateUser(did: string): Promise<any> {
const [existing] = await sql.unsafe("SELECT * FROM rforum.users WHERE did = $1", [did]);
if (existing) return existing;
const [user] = await sql.unsafe(
"INSERT INTO rforum.users (did) VALUES ($1) RETURNING *",
[did],
);
return user;
}
// ── API: List instances ──
routes.get("/api/instances", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub);
const rows = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC",
[user.id],
);
return c.json({ instances: rows });
});
// ── API: Create instance ──
routes.post("/api/instances", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub);
const body = await c.req.json<{
name: string;
subdomain: string;
region?: string;
size?: string;
admin_email: string;
smtp_config?: Record<string, unknown>;
}>();
if (!body.name || !body.subdomain || !body.admin_email) {
return c.json({ error: "name, subdomain, and admin_email are required" }, 400);
}
const domain = `${body.subdomain}.rforum.online`;
// Check uniqueness
const [existing] = await sql.unsafe("SELECT id FROM rforum.instances WHERE domain = $1", [domain]);
if (existing) return c.json({ error: "Domain already taken" }, 409);
const [instance] = await sql.unsafe(
`INSERT INTO rforum.instances (user_id, name, domain, region, size, admin_email, smtp_config)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING *`,
[user.id, body.name, domain, body.region || "nbg1", body.size || "cx22", body.admin_email, JSON.stringify(body.smtp_config || {})],
);
// Start provisioning asynchronously
provisionInstance(instance.id).catch((e) => {
console.error("[Forum] Provision failed:", e);
});
return c.json({ instance }, 201);
});
// ── API: Get instance detail ──
routes.get("/api/instances/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub);
const [instance] = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
[c.req.param("id"), user.id],
);
if (!instance) return c.json({ error: "Instance not found" }, 404);
const logs = await sql.unsafe(
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
[instance.id],
);
return c.json({ instance, logs });
});
// ── API: Destroy instance ──
routes.delete("/api/instances/:id", async (c) => {
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
const user = await getOrCreateUser(claims.sub);
const [instance] = await sql.unsafe(
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
[c.req.param("id"), user.id],
);
if (!instance) return c.json({ error: "Instance not found" }, 404);
if (instance.status === "destroyed") return c.json({ error: "Already destroyed" }, 400);
// Destroy asynchronously
destroyInstance(instance.id).catch((e) => {
console.error("[Forum] Destroy failed:", e);
});
return c.json({ message: "Destroying instance...", instance: { ...instance, status: "destroying" } });
});
// ── API: Get provision logs ──
routes.get("/api/instances/:id/logs", async (c) => {
const logs = await sql.unsafe(
"SELECT * FROM rforum.provision_logs WHERE instance_id = $1 ORDER BY created_at ASC",
[c.req.param("id")],
);
return c.json({ logs });
});
// ── API: Health ──
routes.get("/api/health", (c) => {
return c.json({ status: "ok", service: "rforum" });
});
// ── Page route ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "demo";
return c.html(renderShell({
title: `${spaceSlug} — Forum | rSpace`,
moduleId: "rforum",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-forum-dashboard space="${spaceSlug}"></folk-forum-dashboard>`,
scripts: `<script type="module" src="/modules/forum/folk-forum-dashboard.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/forum/forum.css">`,
}));
});
export const forumModule: RSpaceModule = {
id: "rforum",
name: "rForum",
icon: "\uD83D\uDCAC",
description: "Deploy and manage Discourse forums",
routes,
standaloneDomain: "rforum.online",
};

View File

@ -0,0 +1,524 @@
/**
* <folk-budget-river> animated SVG sankey river visualization.
* Pure renderer: receives nodes via setNodes() or falls back to demo data.
* Parent component (folk-funds-app) handles data fetching and mapping.
*/
import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData, SufficiencyState } from "../lib/types";
import { computeSufficiencyState, computeSystemSufficiency, simulateTick, DEFAULT_CONFIG } from "../lib/simulation";
import { demoNodes } from "../lib/presets";
// ─── Layout types ───────────────────────────────────────
interface RiverLayout {
sources: SourceLayout[];
funnels: FunnelLayout[];
outcomes: OutcomeLayout[];
sourceWaterfalls: WaterfallLayout[];
overflowBranches: BranchLayout[];
spendingWaterfalls: WaterfallLayout[];
width: number;
height: number;
}
interface SourceLayout { id: string; label: string; flowRate: number; x: number; y: number; width: number; }
interface FunnelLayout { id: string; label: string; data: FunnelNodeData; x: number; y: number; riverWidth: number; segmentLength: number; layer: number; status: "healthy" | "overflow" | "critical"; sufficiency: SufficiencyState; }
interface OutcomeLayout { id: string; label: string; data: OutcomeNodeData; x: number; y: number; poolWidth: number; fillPercent: number; }
interface WaterfallLayout { id: string; sourceId: string; targetId: string; label: string; percentage: number; x: number; xSource: number; yStart: number; yEnd: number; width: number; riverEndWidth: number; farEndWidth: number; direction: "inflow" | "outflow"; color: string; flowAmount: number; }
interface BranchLayout { sourceId: string; targetId: string; percentage: number; x1: number; y1: number; x2: number; y2: number; width: number; color: string; }
// ─── Constants ───────────────────────────────────────────
const LAYER_HEIGHT = 160;
const WATERFALL_HEIGHT = 120;
const GAP = 40;
const MIN_RIVER_WIDTH = 24;
const MAX_RIVER_WIDTH = 100;
const MIN_WATERFALL_WIDTH = 4;
const SEGMENT_LENGTH = 200;
const POOL_WIDTH = 100;
const POOL_HEIGHT = 60;
const SOURCE_HEIGHT = 40;
const COLORS = {
sourceWaterfall: "#10b981",
riverHealthy: ["#0ea5e9", "#06b6d4"],
riverOverflow: ["#f59e0b", "#fbbf24"],
riverCritical: ["#ef4444", "#f87171"],
riverSufficient: ["#fbbf24", "#10b981"],
overflowBranch: "#f59e0b",
spendingWaterfall: ["#8b5cf6", "#ec4899", "#06b6d4", "#3b82f6", "#10b981", "#6366f1"],
outcomePool: "#3b82f6",
goldenGlow: "#fbbf24",
bg: "#0f172a",
text: "#e2e8f0",
textMuted: "#94a3b8",
};
function distributeWidths(percentages: number[], totalAvailable: number, minWidth: number): number[] {
const totalPct = percentages.reduce((s, p) => s + p, 0);
if (totalPct === 0) return percentages.map(() => minWidth);
let widths = percentages.map((p) => (p / totalPct) * totalAvailable);
const belowMin = widths.filter((w) => w < minWidth);
if (belowMin.length > 0 && belowMin.length < widths.length) {
const deficit = belowMin.reduce((s, w) => s + (minWidth - w), 0);
const aboveMinTotal = widths.filter((w) => w >= minWidth).reduce((s, w) => s + w, 0);
widths = widths.map((w) => {
if (w < minWidth) return minWidth;
return Math.max(minWidth, w - (w / aboveMinTotal) * deficit);
});
}
return widths;
}
// ─── Layout engine (faithful port) ──────────────────────
function computeLayout(nodes: FlowNode[]): RiverLayout {
const funnelNodes = nodes.filter((n) => n.type === "funnel");
const outcomeNodes = nodes.filter((n) => n.type === "outcome");
const sourceNodes = nodes.filter((n) => n.type === "source");
const overflowTargets = new Set<string>();
const spendingTargets = new Set<string>();
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
data.overflowAllocations?.forEach((a) => overflowTargets.add(a.targetId));
data.spendingAllocations?.forEach((a) => spendingTargets.add(a.targetId));
});
const rootFunnels = funnelNodes.filter((n) => !overflowTargets.has(n.id));
const funnelLayers = new Map<string, number>();
rootFunnels.forEach((n) => funnelLayers.set(n.id, 0));
const queue = [...rootFunnels];
while (queue.length > 0) {
const current = queue.shift()!;
const data = current.data as FunnelNodeData;
const parentLayer = funnelLayers.get(current.id) ?? 0;
data.overflowAllocations?.forEach((a) => {
const child = funnelNodes.find((n) => n.id === a.targetId);
if (child && !funnelLayers.has(child.id)) {
funnelLayers.set(child.id, parentLayer + 1);
queue.push(child);
}
});
}
const layerGroups = new Map<number, FlowNode[]>();
funnelNodes.forEach((n) => {
const layer = funnelLayers.get(n.id) ?? 0;
if (!layerGroups.has(layer)) layerGroups.set(layer, []);
layerGroups.get(layer)!.push(n);
});
const maxLayer = Math.max(...Array.from(layerGroups.keys()), 0);
const sourceLayerY = GAP;
const funnelStartY = sourceLayerY + SOURCE_HEIGHT + WATERFALL_HEIGHT + GAP;
const funnelLayouts: FunnelLayout[] = [];
for (let layer = 0; layer <= maxLayer; layer++) {
const layerNodes = layerGroups.get(layer) || [];
const layerY = funnelStartY + layer * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP);
const totalWidth = layerNodes.length * SEGMENT_LENGTH + (layerNodes.length - 1) * GAP * 2;
layerNodes.forEach((n, i) => {
const data = n.data as FunnelNodeData;
const fillRatio = Math.min(1, data.currentValue / (data.maxCapacity || 1));
const riverWidth = MIN_RIVER_WIDTH + fillRatio * (MAX_RIVER_WIDTH - MIN_RIVER_WIDTH);
const x = -totalWidth / 2 + i * (SEGMENT_LENGTH + GAP * 2);
const status: "healthy" | "overflow" | "critical" =
data.currentValue > data.maxThreshold ? "overflow" :
data.currentValue < data.minThreshold ? "critical" : "healthy";
funnelLayouts.push({ id: n.id, label: data.label, data, x, y: layerY, riverWidth, segmentLength: SEGMENT_LENGTH, layer, status, sufficiency: computeSufficiencyState(data) });
});
}
// Source layouts
const sourceLayouts: SourceLayout[] = sourceNodes.map((n, i) => {
const data = n.data as SourceNodeData;
const totalWidth = sourceNodes.length * 120 + (sourceNodes.length - 1) * GAP;
return { id: n.id, label: data.label, flowRate: data.flowRate, x: -totalWidth / 2 + i * (120 + GAP), y: sourceLayerY, width: 120 };
});
// Source waterfalls
const inflowsByFunnel = new Map<string, { sourceNodeId: string; allocIndex: number; flowAmount: number; percentage: number }[]>();
sourceNodes.forEach((sn) => {
const data = sn.data as SourceNodeData;
data.targetAllocations?.forEach((alloc, i) => {
const flowAmount = (alloc.percentage / 100) * data.flowRate;
if (!inflowsByFunnel.has(alloc.targetId)) inflowsByFunnel.set(alloc.targetId, []);
inflowsByFunnel.get(alloc.targetId)!.push({ sourceNodeId: sn.id, allocIndex: i, flowAmount, percentage: alloc.percentage });
});
});
const sourceWaterfalls: WaterfallLayout[] = [];
sourceNodes.forEach((sn) => {
const data = sn.data as SourceNodeData;
const sourceLayout = sourceLayouts.find((s) => s.id === sn.id);
if (!sourceLayout) return;
data.targetAllocations?.forEach((alloc, allocIdx) => {
const targetLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
if (!targetLayout) return;
const flowAmount = (alloc.percentage / 100) * data.flowRate;
const allInflowsToTarget = inflowsByFunnel.get(alloc.targetId) || [];
const totalInflowToTarget = allInflowsToTarget.reduce((s, i) => s + i.flowAmount, 0);
const share = totalInflowToTarget > 0 ? flowAmount / totalInflowToTarget : 1;
const riverEndWidth = Math.max(MIN_WATERFALL_WIDTH, share * targetLayout.riverWidth);
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * sourceLayout.width * 0.8);
const myIndex = allInflowsToTarget.findIndex((i) => i.sourceNodeId === sn.id && i.allocIndex === allocIdx);
const inflowWidths = distributeWidths(allInflowsToTarget.map((i) => i.flowAmount), targetLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
const startX = targetLayout.x + targetLayout.segmentLength * 0.15;
let offsetX = 0;
for (let k = 0; k < myIndex; k++) offsetX += inflowWidths[k];
const riverCenterX = startX + offsetX + inflowWidths[myIndex] / 2;
const sourceCenterX = sourceLayout.x + sourceLayout.width / 2;
sourceWaterfalls.push({ id: `src-wf-${sn.id}-${alloc.targetId}`, sourceId: sn.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: sourceCenterX, yStart: sourceLayout.y + SOURCE_HEIGHT, yEnd: targetLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "inflow", color: COLORS.sourceWaterfall, flowAmount });
});
});
// Implicit waterfalls for root funnels without source nodes
if (sourceNodes.length === 0) {
rootFunnels.forEach((rn) => {
const data = rn.data as FunnelNodeData;
if (data.inflowRate <= 0) return;
const layout = funnelLayouts.find((f) => f.id === rn.id);
if (!layout) return;
sourceWaterfalls.push({ id: `implicit-wf-${rn.id}`, sourceId: "implicit", targetId: rn.id, label: `$${Math.floor(data.inflowRate)}/mo`, percentage: 100, x: layout.x + layout.segmentLength / 2, xSource: layout.x + layout.segmentLength / 2, yStart: GAP, yEnd: layout.y, width: layout.riverWidth, riverEndWidth: layout.riverWidth, farEndWidth: Math.max(MIN_WATERFALL_WIDTH, layout.riverWidth * 0.4), direction: "inflow", color: COLORS.sourceWaterfall, flowAmount: data.inflowRate });
});
}
// Overflow branches
const overflowBranches: BranchLayout[] = [];
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
if (!parentLayout) return;
data.overflowAllocations?.forEach((alloc) => {
const childLayout = funnelLayouts.find((f) => f.id === alloc.targetId);
if (!childLayout) return;
const width = Math.max(MIN_WATERFALL_WIDTH, (alloc.percentage / 100) * parentLayout.riverWidth);
overflowBranches.push({ sourceId: n.id, targetId: alloc.targetId, percentage: alloc.percentage, x1: parentLayout.x + parentLayout.segmentLength, y1: parentLayout.y + parentLayout.riverWidth / 2, x2: childLayout.x, y2: childLayout.y + childLayout.riverWidth / 2, width, color: alloc.color || COLORS.overflowBranch });
});
});
// Outcome layouts
const outcomeY = funnelStartY + (maxLayer + 1) * (LAYER_HEIGHT + WATERFALL_HEIGHT + GAP) + WATERFALL_HEIGHT;
const totalOutcomeWidth = outcomeNodes.length * (POOL_WIDTH + GAP) - GAP;
const outcomeLayouts: OutcomeLayout[] = outcomeNodes.map((n, i) => {
const data = n.data as OutcomeNodeData;
const fillPercent = data.fundingTarget > 0 ? Math.min(100, (data.fundingReceived / data.fundingTarget) * 100) : 0;
return { id: n.id, label: data.label, data, x: -totalOutcomeWidth / 2 + i * (POOL_WIDTH + GAP), y: outcomeY, poolWidth: POOL_WIDTH, fillPercent };
});
// Spending waterfalls
const spendingWaterfalls: WaterfallLayout[] = [];
funnelNodes.forEach((n) => {
const data = n.data as FunnelNodeData;
const parentLayout = funnelLayouts.find((f) => f.id === n.id);
if (!parentLayout) return;
const allocations = data.spendingAllocations || [];
if (allocations.length === 0) return;
const percentages = allocations.map((a) => a.percentage);
const slotWidths = distributeWidths(percentages, parentLayout.segmentLength * 0.7, MIN_WATERFALL_WIDTH);
const riverEndWidths = distributeWidths(percentages, parentLayout.riverWidth, MIN_WATERFALL_WIDTH);
const startX = parentLayout.x + parentLayout.segmentLength * 0.15;
let offsetX = 0;
allocations.forEach((alloc, i) => {
const outcomeLayout = outcomeLayouts.find((o) => o.id === alloc.targetId);
if (!outcomeLayout) return;
const riverEndWidth = riverEndWidths[i];
const farEndWidth = Math.max(MIN_WATERFALL_WIDTH, outcomeLayout.poolWidth * 0.6);
const riverCenterX = startX + offsetX + slotWidths[i] / 2;
offsetX += slotWidths[i];
const poolCenterX = outcomeLayout.x + outcomeLayout.poolWidth / 2;
spendingWaterfalls.push({ id: `spend-wf-${n.id}-${alloc.targetId}`, sourceId: n.id, targetId: alloc.targetId, label: `${alloc.percentage}%`, percentage: alloc.percentage, x: riverCenterX, xSource: poolCenterX, yStart: parentLayout.y + parentLayout.riverWidth + 4, yEnd: outcomeLayout.y, width: riverEndWidth, riverEndWidth, farEndWidth, direction: "outflow", color: alloc.color || COLORS.spendingWaterfall[i % COLORS.spendingWaterfall.length], flowAmount: (alloc.percentage / 100) * (data.inflowRate || 1) });
});
});
// Compute bounds and normalize
const allX = [...funnelLayouts.map((f) => f.x), ...funnelLayouts.map((f) => f.x + f.segmentLength), ...outcomeLayouts.map((o) => o.x), ...outcomeLayouts.map((o) => o.x + o.poolWidth), ...sourceLayouts.map((s) => s.x), ...sourceLayouts.map((s) => s.x + s.width)];
const allY = [...funnelLayouts.map((f) => f.y + f.riverWidth), ...outcomeLayouts.map((o) => o.y + POOL_HEIGHT), sourceLayerY];
const minX = Math.min(...allX, -100);
const maxX = Math.max(...allX, 100);
const maxY = Math.max(...allY, 400);
const padding = 80;
const offsetXGlobal = -minX + padding;
const offsetYGlobal = padding;
funnelLayouts.forEach((f) => { f.x += offsetXGlobal; f.y += offsetYGlobal; });
outcomeLayouts.forEach((o) => { o.x += offsetXGlobal; o.y += offsetYGlobal; });
sourceLayouts.forEach((s) => { s.x += offsetXGlobal; s.y += offsetYGlobal; });
sourceWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
overflowBranches.forEach((b) => { b.x1 += offsetXGlobal; b.y1 += offsetYGlobal; b.x2 += offsetXGlobal; b.y2 += offsetYGlobal; });
spendingWaterfalls.forEach((w) => { w.x += offsetXGlobal; w.xSource += offsetXGlobal; w.yStart += offsetYGlobal; w.yEnd += offsetYGlobal; });
return { sources: sourceLayouts, funnels: funnelLayouts, outcomes: outcomeLayouts, sourceWaterfalls, overflowBranches, spendingWaterfalls, width: maxX - minX + padding * 2, height: maxY + offsetYGlobal + padding };
}
// ─── SVG Rendering ──────────────────────────────────────
function renderWaterfall(wf: WaterfallLayout): string {
const isInflow = wf.direction === "inflow";
const height = wf.yEnd - wf.yStart;
if (height <= 0) return "";
const topWidth = isInflow ? wf.farEndWidth : wf.riverEndWidth;
const bottomWidth = isInflow ? wf.riverEndWidth : wf.farEndWidth;
const topCx = isInflow ? wf.xSource : wf.x;
const bottomCx = isInflow ? wf.x : wf.xSource;
const cpFrac1 = isInflow ? 0.55 : 0.2;
const cpFrac2 = isInflow ? 0.75 : 0.45;
const cpY1 = wf.yStart + height * cpFrac1;
const cpY2 = wf.yStart + height * cpFrac2;
const tl = topCx - topWidth / 2;
const tr = topCx + topWidth / 2;
const bl = bottomCx - bottomWidth / 2;
const br = bottomCx + bottomWidth / 2;
const shapePath = `M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd} L ${br} ${wf.yEnd} C ${br} ${cpY2}, ${tr} ${cpY1}, ${tr} ${wf.yStart} Z`;
const clipId = `sankey-clip-${wf.id}`;
const gradId = `sankey-grad-${wf.id}`;
const pathMinX = Math.min(tl, bl) - 5;
const pathMaxW = Math.max(topWidth, bottomWidth) + 10;
return `
<defs>
<clipPath id="${clipId}"><path d="${shapePath}"/></clipPath>
<linearGradient id="${gradId}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.85 : 0.5}"/>
<stop offset="50%" stop-color="${wf.color}" stop-opacity="0.65"/>
<stop offset="100%" stop-color="${wf.color}" stop-opacity="${isInflow ? 0.35 : 0.85}"/>
</linearGradient>
</defs>
<path d="${shapePath}" fill="${wf.color}" opacity="0.08"/>
<path d="${shapePath}" fill="url(#${gradId})"/>
<g clip-path="url(#${clipId})">
${[0, 1, 2].map((i) => `<rect x="${pathMinX}" y="${wf.yStart - height}" width="${pathMaxW}" height="${height}" fill="${wf.color}" opacity="0.12" style="animation:waterFlow ${1.4 + i * 0.3}s linear infinite;animation-delay:${i * -0.4}s"/>`).join("")}
</g>
<path d="M ${tl} ${wf.yStart} C ${tl} ${cpY1}, ${bl} ${cpY2}, ${bl} ${wf.yEnd}" fill="none" stroke="${wf.color}" stroke-width="1" opacity="0.3" stroke-dasharray="4 6" style="animation:riverCurrent 1s linear infinite"/>`;
}
function renderBranch(b: BranchLayout): string {
const dx = b.x2 - b.x1;
const dy = b.y2 - b.y1;
const cpx = b.x1 + dx * 0.5;
const halfW = b.width / 2;
return `
<path d="M ${b.x1} ${b.y1 - halfW} C ${cpx} ${b.y1 - halfW}, ${cpx} ${b.y2 - halfW}, ${b.x2} ${b.y2 - halfW} L ${b.x2} ${b.y2 + halfW} C ${cpx} ${b.y2 + halfW}, ${cpx} ${b.y1 + halfW}, ${b.x1} ${b.y1 + halfW} Z" fill="${b.color}" opacity="0.35"/>
<text x="${(b.x1 + b.x2) / 2}" y="${(b.y1 + b.y2) / 2 - 8}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">${b.percentage}%</text>`;
}
function renderSource(s: SourceLayout): string {
return `
<rect x="${s.x}" y="${s.y}" width="${s.width}" height="${SOURCE_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
<text x="${s.x + s.width / 2}" y="${s.y + 16}" text-anchor="middle" fill="${COLORS.text}" font-size="11" font-weight="600">${esc(s.label)}</text>
<text x="${s.x + s.width / 2}" y="${s.y + 30}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">$${s.flowRate.toLocaleString()}/mo</text>`;
}
function renderFunnel(f: FunnelLayout): string {
const colors = f.status === "overflow" ? COLORS.riverOverflow : f.status === "critical" ? COLORS.riverCritical : f.sufficiency === "sufficient" || f.sufficiency === "abundant" ? COLORS.riverSufficient : COLORS.riverHealthy;
const gradId = `river-grad-${f.id}`;
const fillRatio = f.data.currentValue / (f.data.maxCapacity || 1);
const threshold = f.data.sufficientThreshold ?? f.data.maxThreshold;
const isSufficient = f.sufficiency === "sufficient" || f.sufficiency === "abundant";
return `
<defs>
<linearGradient id="${gradId}" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="${colors[0]}" stop-opacity="0.7"/>
<stop offset="50%" stop-color="${colors[1] || colors[0]}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${colors[0]}" stop-opacity="0.7"/>
</linearGradient>
</defs>
${isSufficient ? `<rect x="${f.x - 4}" y="${f.y - 4}" width="${f.segmentLength + 8}" height="${f.riverWidth + 8}" rx="6" fill="none" stroke="${COLORS.goldenGlow}" stroke-width="2" opacity="0.6" style="animation:shimmer 2s ease-in-out infinite"/>` : ""}
<rect x="${f.x}" y="${f.y}" width="${f.segmentLength}" height="${f.riverWidth}" rx="4" fill="url(#${gradId})"/>
${[0, 1, 2].map((i) => `<rect x="${f.x}" y="${f.y + (f.riverWidth / 4) * i}" width="${f.segmentLength}" height="${f.riverWidth / 4}" fill="${colors[0]}" opacity="0.08" style="animation:waterFlow ${2 + i * 0.5}s linear infinite;animation-delay:${i * -0.6}s"/>`).join("")}
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 12}" text-anchor="middle" fill="${COLORS.text}" font-size="13" font-weight="600">${esc(f.label)}</text>
<text x="${f.x + f.segmentLength / 2}" y="${f.y - 2}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="10">$${Math.floor(f.data.currentValue).toLocaleString()} / $${Math.floor(threshold).toLocaleString()} ${isSufficient ? "\u2728" : ""}</text>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength}" height="3" rx="1.5" fill="#334155"/>
<rect x="${f.x}" y="${f.y + f.riverWidth + 4}" width="${f.segmentLength * fillRatio}" height="3" rx="1.5" fill="${colors[0]}"/>`;
}
function renderOutcome(o: OutcomeLayout): string {
const filled = (o.fillPercent / 100) * POOL_HEIGHT;
const color = o.data.status === "completed" ? "#10b981" : o.data.status === "blocked" ? "#ef4444" : "#3b82f6";
return `
<rect x="${o.x}" y="${o.y}" width="${o.poolWidth}" height="${POOL_HEIGHT}" rx="8" fill="#1e293b" stroke="#334155"/>
<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="${filled}" rx="6" fill="${color}" opacity="0.4"/>
${filled > 5 ? `<rect x="${o.x + 2}" y="${o.y + POOL_HEIGHT - filled}" width="${o.poolWidth - 4}" height="3" rx="1.5" fill="${color}" opacity="0.6" style="animation:waveFloat 2s ease-in-out infinite"/>` : ""}
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 14}" text-anchor="middle" fill="${COLORS.text}" font-size="10" font-weight="500">${esc(o.label)}</text>
<text x="${o.x + o.poolWidth / 2}" y="${o.y + POOL_HEIGHT + 26}" text-anchor="middle" fill="${COLORS.textMuted}" font-size="9">${Math.round(o.fillPercent)}%</text>`;
}
function renderSufficiencyBadge(score: number, x: number, y: number): string {
const pct = Math.round(score * 100);
const color = pct >= 90 ? COLORS.goldenGlow : pct >= 60 ? "#10b981" : pct >= 30 ? "#f59e0b" : "#ef4444";
const circumference = 2 * Math.PI * 18;
const dashoffset = circumference * (1 - score);
return `
<g transform="translate(${x}, ${y})">
<circle cx="24" cy="24" r="22" fill="#1e293b" stroke="#334155" stroke-width="1.5"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="#334155" stroke-width="3"/>
<circle cx="24" cy="24" r="18" fill="none" stroke="${color}" stroke-width="3" stroke-dasharray="${circumference}" stroke-dashoffset="${dashoffset}" transform="rotate(-90 24 24)" stroke-linecap="round"/>
<text x="24" y="22" text-anchor="middle" fill="${color}" font-size="11" font-weight="700">${pct}%</text>
<text x="24" y="34" text-anchor="middle" fill="${COLORS.textMuted}" font-size="7">ENOUGH</text>
</g>`;
}
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ─── Web Component ──────────────────────────────────────
class FolkBudgetRiver extends HTMLElement {
private shadow: ShadowRoot;
private nodes: FlowNode[] = [];
private simulating = false;
private simTimer: ReturnType<typeof setInterval> | null = null;
private dragging = false;
private dragStartX = 0;
private dragStartY = 0;
private scrollStartX = 0;
private scrollStartY = 0;
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
static get observedAttributes() { return ["simulate"]; }
connectedCallback() {
this.simulating = this.getAttribute("simulate") === "true";
if (this.nodes.length === 0) {
this.nodes = [...demoNodes.map((n) => ({ ...n, data: { ...n.data } }))];
}
this.render();
if (this.simulating) this.startSimulation();
}
disconnectedCallback() {
this.stopSimulation();
}
attributeChangedCallback(name: string, _: string, newVal: string) {
if (name === "simulate") {
this.simulating = newVal === "true";
if (this.simulating) this.startSimulation();
else this.stopSimulation();
}
}
setNodes(nodes: FlowNode[]) {
this.nodes = nodes;
this.render();
}
private startSimulation() {
if (this.simTimer) return;
this.simTimer = setInterval(() => {
this.nodes = simulateTick(this.nodes, DEFAULT_CONFIG);
this.render();
}, 500);
}
private stopSimulation() {
if (this.simTimer) { clearInterval(this.simTimer); this.simTimer = null; }
}
private render() {
const layout = computeLayout(this.nodes);
const score = computeSystemSufficiency(this.nodes);
this.shadow.innerHTML = `
<style>
:host { display: block; }
.container { position: relative; overflow: auto; background: ${COLORS.bg}; border-radius: 12px; border: 1px solid #334155; max-height: 85vh; cursor: grab; }
.container.dragging { cursor: grabbing; user-select: none; }
svg { display: block; }
.controls { position: absolute; top: 12px; left: 12px; display: flex; gap: 8px; }
.controls button { padding: 6px 12px; border-radius: 6px; border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer; font-size: 12px; }
.controls button:hover { border-color: #6366f1; color: #f1f5f9; }
.controls button.active { background: #4f46e5; border-color: #6366f1; color: #fff; }
.legend { position: absolute; bottom: 12px; left: 12px; background: rgba(15,23,42,0.9); border: 1px solid #334155; border-radius: 8px; padding: 8px 12px; font-size: 10px; color: #94a3b8; }
.legend-item { display: flex; align-items: center; gap: 6px; margin: 2px 0; }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
@keyframes waterFlow { 0% { transform: translateY(0); } 100% { transform: translateY(100%); } }
@keyframes riverCurrent { 0% { stroke-dashoffset: 10; } 100% { stroke-dashoffset: 0; } }
@keyframes shimmer { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.8; } }
@keyframes waveFloat { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
</style>
<div class="container">
<svg viewBox="0 0 ${layout.width} ${layout.height}" width="${layout.width}" height="${layout.height}">
${layout.sourceWaterfalls.map(renderWaterfall).join("")}
${layout.spendingWaterfalls.map(renderWaterfall).join("")}
${layout.overflowBranches.map(renderBranch).join("")}
${layout.sources.map(renderSource).join("")}
${layout.funnels.map(renderFunnel).join("")}
${layout.outcomes.map(renderOutcome).join("")}
${renderSufficiencyBadge(score, layout.width - 70, 10)}
</svg>
<div class="controls">
<button class="${this.simulating ? "active" : ""}" data-action="toggle-sim">${this.simulating ? "\u23F8 Pause" : "\u25B6 Simulate"}</button>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#10b981"></div> Inflow</div>
<div class="legend-item"><div class="legend-dot" style="background:#0ea5e9"></div> Healthy</div>
<div class="legend-item"><div class="legend-dot" style="background:#f59e0b"></div> Overflow</div>
<div class="legend-item"><div class="legend-dot" style="background:#ef4444"></div> Critical</div>
<div class="legend-item"><div class="legend-dot" style="background:#8b5cf6"></div> Spending</div>
<div class="legend-item"><div class="legend-dot" style="background:#fbbf24"></div> Sufficient</div>
</div>
</div>`;
this.shadow.querySelector("[data-action=toggle-sim]")?.addEventListener("click", () => {
this.simulating = !this.simulating;
if (this.simulating) this.startSimulation();
else this.stopSimulation();
this.render();
});
// Drag-to-pan
const container = this.shadow.querySelector(".container") as HTMLElement;
if (container) {
container.addEventListener("pointerdown", (e: PointerEvent) => {
if ((e.target as HTMLElement).closest("button")) return;
this.dragging = true;
this.dragStartX = e.clientX;
this.dragStartY = e.clientY;
this.scrollStartX = container.scrollLeft;
this.scrollStartY = container.scrollTop;
container.classList.add("dragging");
container.setPointerCapture(e.pointerId);
});
container.addEventListener("pointermove", (e: PointerEvent) => {
if (!this.dragging) return;
container.scrollLeft = this.scrollStartX - (e.clientX - this.dragStartX);
container.scrollTop = this.scrollStartY - (e.clientY - this.dragStartY);
});
container.addEventListener("pointerup", (e: PointerEvent) => {
this.dragging = false;
container.classList.remove("dragging");
container.releasePointerCapture(e.pointerId);
});
// Auto-center on initial render
container.scrollLeft = (container.scrollWidth - container.clientWidth) / 2;
container.scrollTop = 0;
}
}
}
customElements.define("folk-budget-river", FolkBudgetRiver);

Some files were not shown because too many files have changed in this diff Show More