Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
3 changes: 2 additions & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

src/webui/remix.config.js
src/webui/remix.config.js
src/published/remix.config.js
13 changes: 12 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ module.exports = {
react: { version: 'detect' },
'import/resolver': {
typescript: {
project: ['src/webui/tsconfig.json', 'src/core/tsconfig.json'],
project: [
'src/webui/tsconfig.json',
'src/published/tsconfig.json',
'src/core/tsconfig.json',
],
},
},
},
Expand All @@ -57,6 +61,13 @@ module.exports = {
project: ['src/webui/tsconfig.json'],
},
},
{
files: ['src/published/**/*.{ts,tsx}'],
parserOptions: {
tsconfigRootDir: __dirname,
project: ['src/published/tsconfig.json'],
},
},
{
files: ['src/core/**/*.{ts,tsx}'],
parserOptions: {
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ site
.cache/
src/webui/build/
src/webui/public/build/
src/published/build/
src/published/public/build/
.env
gen/ts/webapi-client/dist/
.sqlite
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Thrive (codenamed Jupiter) is a life planning tool with a monorepo containing:
|---|---|
| **WebAPI** (backend) | Python/FastAPI, SQLite |
| **WebUI** (frontend) | TypeScript/Remix/React |
| **Published** (public pages) | TypeScript/Remix/React |
| **API** | Python/FastAPI |
| **MCP** | Python/FastAPI |
| **Docs** | Python/MkDocs |
Expand All @@ -27,7 +28,7 @@ mise run prepare

### Running services

Start all 5 services (WebAPI, API, MCP, WebUI, Docs) via mise:
Start all 6 services (WebAPI, API, MCP, WebUI, Published, Docs) via mise:

```bash
mise run run:srv --instance <instance-name>
Expand Down Expand Up @@ -58,6 +59,7 @@ Current topics include (non-exhaustive; browse the directory):
| 0007 | Search entity indexing and backfill |
| 0008 | WebAPI backend blend via `Config.global` |
| 0009 | Search index method version |
| 0010 | Entity publish mechanism (public sharing) |

## Generating code

Expand Down
164 changes: 164 additions & 0 deletions docs/adrs/0010.entity-publish-mechanism.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# ADR 0010: Entity publish mechanism

## Status

Accepted (2026-06-07)

## Context

Users need to share read-only snapshots of workspace entities with people who do
not have accounts. The product must:

- Keep sharing **opt-in** and **revocable** (draft vs active).
- Avoid exposing the full workspace or arbitrary entity IDs to guests.
- Reuse the same domain load logic for workspace and public views where
possible.
- Support both **single entities** (a doc, a person, a habit) and **composite
trees** (a docs folder and its contents, a schedule stream and its events).

## Decision

### Core model: `PublishEntity`

Each publishable workspace entity may have at most one `PublishEntity`, stored
under the workspace’s `PublishDomain`.

| Field | Role |
| ----- | ---- |
| `owner` | `EntityLink` (`purpose=std`) to the workspace entity being shared |
| `external_id` | UUID v4 used in public URLs (capability-style, unlisted link) |
| `status` | `draft` or `active` — only **active** entities are visible to guests |

Creation and lifecycle mutations are logged-in use cases:

- `PublishEntityCreateUseCase` — creates a **draft** publish entity for an owner
- `PublishEntityActivateUseCase` / `PublishEntityToDraftUseCase` — toggle visibility

Allowed owner types are enumerated in
`ALLOWED_PUBLISH_OWNER_TYPES` in
`src/core/jupiter/core/common/sub/publish/sub/entity/root.py`. New types must be
added there explicitly; creation rejects unknown owner links.

### Public URL entry point

The canonical share link shown in the UI is:

```text
{publishedUrl}/publish/{external_id}
```

The route `src/published/app/routes/publish/$externalId.tsx` loads the
publish entity as a guest, inspects `owner.the_type`, and **redirects** to a
typed public route (for example `/publish/person/{externalId}` or
`/publish/doc/dirtree/{externalId}/{dirRefId}` for directories).

This keeps one stable link for copy/share while allowing entity-specific public
layouts underneath.

### Guest access vs workspace access

| Concern | Workspace (logged in) | Public (guest) |
| ------- | --------------------- | -------------- |
| API client | `getLoggedInApiClient` | `getGuestApiClient` |
| Use case base | `JupiterLoggedIn*` | `JupiterGuestReadonlyUseCase` |
| Publish entity status | Draft and active visible to owner | **Active only** |
| Mutations | Create / activate / draft via publish actions | None (read-only UI) |

Every `*LoadPublic*` use case follows the same validation skeleton:

1. Load `PublishEntity` by `external_id`.
2. Reject if status is not `active`.
3. Assert `owner.the_type` matches the expected entity tag.
4. Resolve workspace scope via `PublishDomain` → parent domain (for example
`DocCollection`, `ScheduleDomain`).
5. Assert the target entity belongs to that workspace.
6. Delegate to the entity’s **load service** with `include_publish_entity=False`.

Shared validation logic for composite trees lives in dedicated services where
needed (for example `DirPublishedLoadService` for docs directories).

Public loader errors are normalized in
`src/published/app/rendering/published-loader.server.ts` so guests see 404 for
missing, inactive, or invalid publish links.

### Workspace UI: managing publish state

The shared UI component is `PublishPanel`
(`src/core/jupiter/core/common/sub/publish/components/publish-panel.tsx`). It
renders create / activate / draft actions and the public URL field. Route
actions handle form intents `create-publish`, `activate-publish`, and
`to-draft-publish`, calling the publish WebAPI mutations and redirecting back.

**Where the panel appears depends on layout type:**

| Layout | Pattern | Examples |
| ------ | ------- | -------- |
| **Leaf** | `LeafPanel` with `publishable` and toolbar publish toggle | Doc, person, habit, chore, … |
| **Branch** | `BranchPanel` with `publishable` | Smart list trunk |
| **Trunk / inline** | Embed `PublishPanel` directly inside route content (own `<Form>`) | Docs folder listing (`$dirId.tsx`) |
| **Publish admin** | Trunk list at Core → Publish; leaf detail embeds `PublishPanel` for an existing `PublishEntity` | `core/publish.tsx`, `$publishEntityId.tsx` |

`TrunkPanel` intentionally does **not** implement publish chrome. Trunk-level
entities that support sharing embed `PublishPanel` in page content instead.

Entity load results used by workspace routes may include an optional
`publish_entity` field (loaded via `PublishEntityRepository.load_optional_for_owner`
inside entity load services when `include_publish_entity=True`).

### Public UI routes

Each allowed owner type has one or more Remix routes under
`src/published/app/routes/publish/`. Public views:

- Use read-only editors or listing components (often the same core components
as workspace, with `inputsEnabled={false}`).
- Use `LeafPanel` for nested navigation where appropriate, but **without**
publish/history affordances.
- Map validation failures to guest-safe 404 responses via
`handlePublishedLoaderError`.

The redirect table in `$externalId.tsx` must stay in sync with
`ALLOWED_PUBLISH_OWNER_TYPES` and implemented public routes.

### Composite and implicit sharing

Some owner types share **more than the single entity** referenced by
`owner`:

| Published owner | What guests can access | Mechanism |
| --------------- | ------------------------ | --------- |
| **DIR** | The folder and all descendant folders and docs | `DirPublishedLoadService` walks parent links to ensure targets sit under the published root; public navigation uses `/doc/dirtree/{externalId}/…` |
| **SCHEDULE_STREAM** | Stream plus in-day and full-days events in that stream | Root `ScheduleStreamLoadPublic`; nested `*LoadPublicFromScheduleStream` use cases validate event membership |
| **SMART_LIST** | List plus items | `SmartListItemLoadPublicFromSmartList` |
| **METRIC** | Metric plus entries | `MetricEntryLoadPublicFromMetric` |

Publishing a parent does **not** automatically create child `PublishEntity`
records. Access is enforced in guest load use cases by checking membership in
the published tree.

### Docs directory routing note

Published directory browsing uses a dedicated `dirtree/` URL branch
(`/publish/doc/dirtree/{externalId}/{dirId}`) rather than nesting
public dir routes under a parent that re-runs redirect loaders on every child
navigation. `/doc/dir/{externalId}` exists only as a redirect into `dirtree/`.

## Consequences

- Adding a new publishable entity type requires: allowlist entry, workspace load
support for `publish_entity` (if applicable), `*LoadPublic*` use case(s),
public WebUI route(s), redirect entry in `$externalId.tsx`, workspace
`PublishPanel` wiring, client codegen, and ideally a WebUI integration test.
- Guest endpoints must never trust client-supplied workspace or entity IDs
without tying them to an active `PublishEntity` and re-validating workspace
membership.
- Draft publish entities are invisible to guests but visible in the owner’s
workspace UI, allowing link preparation before activation.
- Deactivating (moving to draft) immediately revokes guest access without
deleting the publish entity or changing `external_id`.
- Composite types should centralize subtree validation in a service (as with
`DirPublishedLoadService`) rather than duplicating ancestor checks across use
cases.
- Public URLs are unlisted, not authenticated sessions; treat `external_id` as
a capability token and avoid putting sensitive data in entities meant for
sharing.
Loading
Loading