Skip to content

Commit 001cbb4

Browse files
committed
refactor(resources): one canonical view per resource, mounted on source/grants/host
Extracts the file and table views out of their route folders into canonical units under `apps/sim/components/resources/**`, and gives every consumer one way to mount them. Behavior-neutral: no page, no API route, and no migration is added or removed, and every default is preserved. The three axes (`apps/sim/resources/**`, pure TypeScript so a Server Component can build one during SSR): - `source` — where the data comes from and by what address, `WorkspaceSource<K> | ShareSource<K>` discriminated on `via`. Replaces `workspaceId`, `token`, `contentSource`, `isPublic`. `ShareSource` declares `workspaceId?: never`, so a share token can no longer be laundered through a workspace-shaped slot. - `grants` — what this viewer may do: `{ write, run }`. Replaces `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete`. - `host` — who owns the URL, the router, the document frame. Replaces `embedded`; `hostOwnsUrl(host)` is the single home of the "embedded views do not write nuqs keys" rule. `file` is the only kind with a share seed, because `/f/[token]` is the only public route that serves one. Every other kind is `never` and cannot be addressed by a token at all — a compile-time fact rather than a runtime check. Also in this PR: - Public share auth (password / email-OTP / SSO) moves out of `app/f/[token]` into `components/public-share/**`, behind one page-side gate. Security hardening on the way: timing-safe OTP comparison, a per-IP bucket on OTP verify separate from the send path, and share tokens removed from logs. - Log status chrome (`StatusBadge`, `STATUS_CONFIG`, `getDisplayStatus`) moves to `components/execution-status/`, shared instead of per-surface. - `AnchoredContextMenu` and `ActionRow` move to `components/`, which closed a real hole: `file-view` is mounted by `app/f/[token]` for anonymous visitors, and with no `sideEffects: false` its import of the workspace barrel pulled the whole authenticated tree into the public chunk. - emcn: `ChipPasswordInput`, a shared `ChipChevronDown`, and byte-range / untitled-title utilities behind the file preview routes. - Deployed-chat header now renders the shared `Navbar` in `logoOnly` mode, so a public chat wears the same wordmark, geometry, name and "Shared by" credit as the shared file page. This drops the GitHub star chip from that header. - `ActionRow` used `var(--divider)`, which is not a defined token, so its dividers fell back to `currentColor`. Now `var(--border)`. Nothing here advertises a capability it cannot deliver: the `form` trigger type that arrived with this work drives nothing on its own, so it is not registered in the Start block's trigger list, the Logs filter, the execute contract, or the executor's streaming types. It lands with the surface that produces it. Enforced by `bun run check:resources` (added to CI), which ratchets counters for wrappers, imports past a unit barrel, cross-tree imports, unsanctioned props, token-as-workspaceId, and context leaks. The cross-tree counter is re-based to the current tree: every offender it counts is inherited, and this change strictly reduces the number. Verification: apps/sim + 22 packages typecheck, biome clean, 18598 tests pass, and all 15 repo gates pass including both strict variants.
1 parent 6c10ac2 commit 001cbb4

406 files changed

Lines changed: 10736 additions & 5133 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/rules/canon.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Canon — The 7 Canonical Concerns
2+
3+
The non-negotiable conventions every change is measured against. This file is the index — the full rules live in the referenced files. When a canon rule and any other guidance conflict, canon wins. Known gaps are flagged so we close them deliberately instead of re-discovering them.
4+
5+
## 1. Next.js best practices
6+
7+
Server Components by default; `'use client'` only on the smallest leaf. `page.tsx` owns metadata. `next/image` with `priority` on the LCP element; `next/dynamic` for below-fold. Client refs are never called server-side (enforced by `scripts/check-client-boundary-imports.ts`).
8+
9+
- Full rules: `apps/sim/app/(landing)/CLAUDE.md` (landing), `.claude/rules/sim-architecture.md` (server boundary)
10+
- **Gap**: no general app-router conventions rule — when to add `error.tsx` / `loading.tsx` / `not-found.tsx`, `generateMetadata` vs static `metadata`, ISR/`revalidate`, Suspense/streaming. Landing doc covers landing only.
11+
12+
## 2. SEO / GEO (landing only)
13+
14+
One `<h1>` (Hero), strict heading hierarchy, `<section aria-labelledby>`, server-rendered navbar, JSON-LD, answer-first H2s, atomic extractable blocks, entity consistency ("Sim", never "the platform"), sr-only summaries, concrete numbers. Copy follows `.claude/rules/constitution.md`.
15+
16+
- Full rules: `.claude/rules/landing-seo-geo.md`, `apps/sim/app/(landing)/CLAUDE.md`
17+
- **Gap**: docs reference a single `structured-data.tsx` but code is split into `site-structured-data/`, `home-structured-data/`, `json-ld/`. `sitemap.ts` / `robots.ts` / `manifest.ts` conventions (app root) are undocumented.
18+
19+
## 3. Feature file structure
20+
21+
Every feature dir: `feature.tsx` + `page.tsx` (+ `error.tsx`, `loading.tsx`, `search-params.ts` where applicable) + `utils/` (only for 2+ consumers — single-consumer helpers stay in `feature.tsx`) + `hooks/` + `components/`. Every component lives in its own kebab-case folder holding `<name>.tsx` + `index.ts` barrel; children nest under that folder's own `components/`, recursively. Never a bare `<name>.tsx` flat inside a `components/` directory. Reference implementation: `apps/sim/app/workspace/[workspaceId]/scheduled-tasks/`.
22+
23+
- Full rules: `apps/sim/app/(landing)/CLAUDE.md` "Structure", `.claude/rules/sim-architecture.md`
24+
- **Gap**: the recursion + barrel rule is only fully written in the landing CLAUDE.md; `sim-architecture.md` shows a flatter sketch and omits `search-params.ts` / `error.tsx` / `loading.tsx` co-location.
25+
26+
## 4. EMCN components only (platform)
27+
28+
No custom buttons/inputs/menus — always the `@sim/emcn` chip-family equivalent. Components own their chrome; consumers pass props, never chrome via `className` (layout/sizing only).
29+
30+
- Full rules: `.claude/rules/emcn-components.md` (authoring), `.claude/rules/sim-styling.md` (consumer)
31+
- **Gap**: stale paths — EMCN moved to `packages/emcn/` but `emcn-components.md` frontmatter still scopes `apps/sim/components/emcn/**`, and the landing CLAUDE.md still says import from `@/components/emcn`.
32+
33+
## 5. No ad-hoc animations or colors
34+
35+
Colors come from tokens in `apps/sim/app/_styles/globals.css` / `tailwind.config.ts` — never raw hex in components. Animations: prefer CSS, respect `prefers-reduced-motion`, no new keyframes outside the Tailwind config / scoped `.module.css`. Never touch global styles.
36+
37+
- Full rules: `.claude/rules/sim-styling.md` (tokens), `apps/sim/app/(landing)/CLAUDE.md` (motion)
38+
- **Gap**: the positive rule ("declare custom keyframes/tokens HERE and nowhere else") is unwritten — only the prohibition exists.
39+
40+
## 6. State placement (useState / Zustand / React Query / URL)
41+
42+
One four-way decision: React Query = all server state · nuqs URL params = shareable view-state · Zustand = high-frequency, ephemeral, or socket-synced state · useState = purely local UI. Never `useState` + `fetch`; never store-synced-with-effects for view-state.
43+
44+
- Full rules: `.claude/rules/sim-url-state.md` (the canonical 4-way table), `.claude/rules/sim-queries.md`, `.claude/rules/sim-stores.md`
45+
46+
## 7. Meta — authoring skills, rules, and CLAUDE.md files
47+
48+
How we write the docs themselves: CLAUDE.md stays a lean index; detailed conventions go in `.claude/rules/*.md` with `paths:` frontmatter globs so they load only when matching files are touched; repeatable multi-step procedures become skills (`.claude/skills/` or `.claude/commands/`); one-off preferences go in memory, not the repo.
49+
50+
- **Gap**: no authoring guide exists — rule/skill conventions are learned by imitating existing files. Needs a short `.claude/rules/meta-authoring.md`.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
---
2+
paths:
3+
- "apps/sim/resources/**/*.ts"
4+
- "apps/sim/components/resources/**/*.ts"
5+
- "apps/sim/components/resources/**/*.tsx"
6+
- "apps/sim/app/workspace/[workspaceId]/**/*.tsx"
7+
- "apps/sim/app/f/**/*.tsx"
8+
- "apps/sim/app/i/**/*.tsx"
9+
- "apps/sim/app/(interfaces)/**/*.tsx"
10+
- "apps/sim/hooks/queries/workspace-files.ts"
11+
---
12+
13+
# Resource Views
14+
15+
A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace route page, the mothership panel, an interface module, and the public share page.
16+
17+
**One view per resource. Consumers construct the axes and mount it. They never wrap it.**
18+
19+
Enforced by `bun run check:resources` (strict CI gate: `bun run check:resources:strict`), which is `scripts/check-resource-views.ts`.
20+
21+
## The three axes
22+
23+
`apps/sim/resources/**` is pure TypeScript — no React, no `'use client'` — because `app/i/[token]/page.tsx` builds a share source during SSR.
24+
25+
| Axis | Type | Replaces |
26+
| --- | --- | --- |
27+
| `source` | `WorkspaceSource<K> \| ShareSource<K>`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic`, `isShared` |
28+
| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `canDelete`, `disableEdit/Insert/Delete` |
29+
| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` |
30+
31+
There is no fourth axis. Agent streaming is **one optional prop on `FileView`** (`streaming?: FileViewStreaming`), because only files stream.
32+
33+
`ShareSource` declares `workspaceId?: never` and `resourceId?: never`, and `WorkspaceSource` declares `token?: never` and `seed?: never`. A share source **cannot** carry a workspace id — that is a compile error, not a convention. A kind whose seed is typed `never` (`knowledge`, `log`, `schedule`) structurally cannot construct a share source at all: "no public surface" is a compile-time fact.
34+
35+
```
36+
apps/sim/resources/ # kinds.ts · source.ts · grants.ts · host.ts — pure TS
37+
apps/sim/components/resources/<unit>/ # 'use client' — THE view, one per resource
38+
```
39+
40+
A resource kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply **absent** from `CANONICAL_UNITS` in the check. That is the correct state for an unmigrated kind. Do not add a flag, a shim, or a placeholder entry for it.
41+
42+
## Consume: construct the axes, then mount
43+
44+
That is the whole job. Same component, same props; only the constructed values differ.
45+
46+
```typescript
47+
// app/f/[token]/public-file-view.tsx — anonymous share
48+
const source = useMemo(
49+
() => shareSource({ kind: 'file', token, grantId: token, seed: { name, type, size, version } }),
50+
[token, name, type, size, version]
51+
)
52+
return <FileView source={source} grants={grantsForShare('file')} host='public' readOnly />
53+
```
54+
55+
```typescript
56+
// .../mothership-view/.../resource-content.tsx — panel, same view
57+
const source = useMemo(
58+
() => workspaceSource({ kind: 'file', workspaceId, resourceId: file.id }),
59+
[workspaceId, file.id]
60+
)
61+
const grants = useMemo(() => grantsFromPermissions(permissions), [permissions])
62+
return <FileView source={source} grants={grants} host='panel' streaming={streaming} />
63+
```
64+
65+
- Import from the **unit barrel** (`@/components/resources/file-view`), never a file inside it.
66+
- Copy that differs between workspace and share belongs on the **source** (`source.unavailableCopy`), not in the view. A share must never say "workspace"that is what stops the view becoming an existence oracle.
67+
- Links belong on the source too (`source.hrefFor(link)`), which returns `null` in share scope so nobody hand-builds `/workspace/${token}/…`.
68+
- `host` decides chrome and URL ownership. `hostOwnsUrl(host)` is the single place the "embedded views do not write nuqs keys" rule lives.
69+
70+
## Never do this
71+
72+
**Never wrap a view.** A component whose body is a canonical view with its own props forwarded in is a wrapper. `check:resources` fails on the first one (`wrapperMounts` is at `0`).
73+
74+
```typescript
75+
// ✗ Bad — adds a name, a file, and an import hop; adds no behavior.
76+
export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) {
77+
return <FileView source={source} grants={grants} host={host} />
78+
}
79+
80+
// ✓ Good — the consumer constructs the axes and mounts the view itself.
81+
const source = workspaceSource({ kind: 'file', workspaceId, resourceId })
82+
return <FileView source={source} grants={grants} host='panel' />
83+
```
84+
85+
**Never add a fourth spelling.** If the view cannot express what you need, change `source` / `grants` / `host`one place, every consumeror collapse the need into an existing optional object (`streaming`, `editing`). Do not add a loose prop.
86+
87+
```typescript
88+
// ✗ Bad — three axes, spelled four wrong ways.
89+
<FileView workspaceId={id} canEdit embedded streamingContent={text} isAgentEditing />
90+
91+
// ✓ Good
92+
<FileView source={source} grants={grants} host='panel' streaming={{ content: text, isAgentEditing }} />
93+
```
94+
95+
**Never reimplement.** If a view has no seam for what you need, **add the seam**. A hand-rolled mini-table loses booleans, JSON, dates, links, resource chips, pinned columns and windowingevery one of which the real view already handles.
96+
97+
**Never reach past the barrel.**
98+
99+
```typescript
100+
// ✗ Bad — binds you to the unit's private layout
101+
import { resolveFileCategory } from '@/components/resources/file-view/file-category'
102+
103+
// ✓ Good
104+
import { resolveFileCategory } from '@/components/resources/file-view'
105+
```
106+
107+
The one sanctioned exception is a `lazy()` code-split point, where routing through the barrel silently re-attaches the split chunk (`apps/sim` has no `sideEffects: false`). Those go in `INTERNAL_IMPORT_ALLOWLIST` in the check, keyed by importer **and** specifier.
108+
109+
**Never import the workspace route tree from an anonymous surface.** `app/f/**`, `app/(interfaces)/**`, `app/(shared)/**` and public API routes may not import `@/app/workspace/[workspaceId]/**`. Shared units live in `apps/sim/components/resources/**`. Nesting under a `[workspaceId]` segment is exactly why `workspaceId: string` once read as natural on a component anonymous visitors mounted with a **share token**.
110+
111+
**Never read route or permission context inside a unit.** No `useRouter`, `useParams`, `useSearchParams`, `usePathname`, `useQueryState(s)`, or `useUserPermissionsContext` under `apps/sim/components/resources/**`. Addressing is `source`, navigation targets are `source.hrefFor(link)`, capability is `grants`, URL ownership is `host`. A component that falls back to `useParams()` can only ever exist once per page.
112+
113+
**Never put `'use client'` in `apps/sim/resources/**`.** Next rewrites every export of a `'use client'` module into a client reference in the server bundle, so the Server Component that builds a share source would throw at runtime.
114+
115+
## Escape hatch
116+
117+
Four annotations, reason mandatory, on the line directly above the offending mount / import / attribute (up to three preceding comment lines of extra context are tolerated):
118+
119+
```typescript
120+
// boundary-resource-wrapper: <reason>
121+
// boundary-resource-internal: <reason>
122+
// boundary-resource-tree: <reason>
123+
// boundary-resource-prop: <reason>
124+
```
125+
126+
An annotation with an empty reason is still a finding **and** trips `annotationsMissingReason`. Whole-file exceptions go through `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`, not per-line annotations.
127+
128+
## Checklist before you add a component near a resource
129+
130+
1. Does a canonical view already exist for this kind? Mount it.
131+
2. Does it exist but lack a seam? Add the seam in the unit and thread itdo not fork the UI.
132+
3. Is your new component only forwarding props into a view? Delete it; mount the view at the call site.
133+
4. Are you about to write `embedded`, `canEdit`, `canRun`, `isPublic`, `token` or `workspaceId` on a view? Map it to `source` / `grants` / `host`.
134+
5. Run `bun run check:resources`. The success metric is **consumers per view going up and component count going down**.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
---
2+
description: Resource views — one view per resource, many consumers, no wrappers
3+
globs: ["apps/sim/resources/**/*.ts", "apps/sim/components/resources/**/*.ts", "apps/sim/components/resources/**/*.tsx", "apps/sim/app/workspace/[workspaceId]/**/*.tsx", "apps/sim/app/f/**/*.tsx", "apps/sim/app/(interfaces)/**/*.tsx"]
4+
---
5+
# Resource Views
6+
7+
A **resource** is a thing a workspace holds that can also be shared: a file, a table, an interface, a knowledge base, a log, a scheduled task. A resource with a canonical view has **exactly one**, and every consumer mounts that one — the workspace page, the mothership panel, an interface module, the public share page.
8+
9+
**One view per resource. Consumers construct the axes and mount it. They never wrap it.**
10+
11+
Enforced by `bun run check:resources`; strict gate `bun run check:resources:strict`.
12+
13+
## Three axes, nothing else
14+
15+
| Axis | Type | Replaces |
16+
| --- | --- | --- |
17+
| `source` | `WorkspaceSource<K> \| ShareSource<K>`, discriminated on `via` | `workspaceId`, `token`, `contentSource`, `isPublic` |
18+
| `grants` | `{ write: boolean; run: boolean }` | `canEdit`, `canRun`, `canAdmin`, `disableEdit/Insert/Delete` |
19+
| `host` | `'page' \| 'panel' \| 'public'` | `embedded`, `isEmbedded`, `compact`, `minimal` |
20+
21+
There is no fourth axis. Agent streaming is one optional prop on `FileView` (`streaming?`), because only files stream.
22+
23+
`ShareSource` declares `workspaceId?: never`; `WorkspaceSource` declares `token?: never`. A share source cannot carry a workspace id — compile error, not convention.
24+
25+
```
26+
apps/sim/resources/ # kinds/source/grants/host — pure TS, NO 'use client'
27+
apps/sim/components/resources/<unit>/ # 'use client' — THE view, one per resource
28+
```
29+
30+
A kind with no canonical view yet (`table`, `knowledge`, `log`, `schedule`) is simply absent from the check's `CANONICAL_UNITS`. Do not add a flag, shim, or placeholder for it.
31+
32+
## Consume: construct, then mount
33+
34+
```typescript
35+
// Anonymous share
36+
const source = shareSource({ kind: 'file', token, grantId: token, seed })
37+
return <FileView source={source} grants={grantsForShare('file')} host='public' readOnly />
38+
39+
// Panel — same view, same props
40+
const source = workspaceSource({ kind: 'file', workspaceId, resourceId: file.id })
41+
return <FileView source={source} grants={grantsFromPermissions(permissions)} host='panel' />
42+
```
43+
44+
Import from the unit barrel (`@/components/resources/file-view`), never a file inside it. Scope-dependent copy lives on `source.unavailableCopy`; links on `source.hrefFor(link)` (which returns `null` in share scope). `hostOwnsUrl(host)` is the one place the "embedded views do not write nuqs keys" rule lives.
45+
46+
## Never
47+
48+
```typescript
49+
// ✗ Wrapper — adds a name, a file, an import hop; adds no behavior. Fails check:resources.
50+
export function EmbeddedFilePanel({ source, grants, host }: EmbeddedFilePanelProps) {
51+
return <FileView source={source} grants={grants} host={host} />
52+
}
53+
54+
// ✓ The consumer constructs the axes and mounts the view itself
55+
const source = workspaceSource({ kind: 'file', workspaceId, resourceId })
56+
return <FileView source={source} grants={grants} host='panel' />
57+
```
58+
59+
```typescript
60+
// ✗ A fourth spelling for capability / chrome / address
61+
<FileView workspaceId={id} canEdit embedded streamingContent={text} isAgentEditing />
62+
63+
// ✓
64+
<FileView source={source} grants={grants} host='panel' streaming={{ content: text, isAgentEditing }} />
65+
```
66+
67+
```typescript
68+
// ✗ Reaching past the barrel
69+
import { resolveFileCategory } from '@/components/resources/file-view/file-category'
70+
71+
// ✓
72+
import { resolveFileCategory } from '@/components/resources/file-view'
73+
```
74+
75+
- **Never reimplement** a resource's UI because the view has no seam — add the seam. A hand-rolled mini-table loses booleans, JSON, dates, links, chips, pinned columns and windowing.
76+
- **Never import `@/app/workspace/[workspaceId]/**`** from `app/f/**`, `app/(interfaces)/**`, `app/(shared)/**`, or a public API route. Shared units live in `apps/sim/components/resources/**`.
77+
- **Never** use `useRouter` / `useParams` / `useSearchParams` / `usePathname` / `useQueryState(s)` / `useUserPermissionsContext` inside `apps/sim/components/resources/**`. Addressing is `source`, navigation is `source.hrefFor`, capability is `grants`, URL ownership is `host`.
78+
- **Never** put `'use client'` in `apps/sim/resources/**` — a Server Component builds a share source from it during SSR.
79+
80+
## Escape hatch
81+
82+
Reason mandatory, on the line directly above the offending mount / import / attribute:
83+
84+
```typescript
85+
// boundary-resource-wrapper: <reason>
86+
// boundary-resource-internal: <reason>
87+
// boundary-resource-tree: <reason>
88+
// boundary-resource-prop: <reason>
89+
```
90+
91+
An empty reason is still a finding. Whole-file exceptions go in `INTERNAL_IMPORT_ALLOWLIST` / `CROSS_TREE_ALLOWLIST` in `scripts/check-resource-views.ts`. The only sanctioned deep import is a `lazy()` split point, where the barrel would re-attach the split chunk.
92+
93+
## Before adding a component near a resource
94+
95+
1. Does a canonical view exist for this kind? Mount it.
96+
2. Exists but lacks a seam? Add the seam in the unit — do not fork.
97+
3. Only forwarding props into a view? Delete it; mount the view at the call site.
98+
4. About to write `embedded` / `canEdit` / `canRun` / `isPublic` / `token` / `workspaceId` on a view? Map it to `source` / `grants` / `host`.
99+
5. Run `bun run check:resources`. Success is consumers-per-view up, component count down.

.github/workflows/test-build.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ jobs:
123123
- name: API contract boundary audit
124124
run: bun run check:api-validation:strict
125125

126+
- name: Resource view boundary audit
127+
run: bun run check:resources
128+
126129
- name: Desktop bridge contract audit
127130
run: bun run check:desktop-bridge
128131

@@ -181,11 +184,6 @@ jobs:
181184
- name: Type-check realtime server
182185
run: bunx turbo run type-check --filter=@sim/realtime
183186

184-
# cloud-review-tools.test.ts runs the real helper on the runner, which shells
185-
# out to rg. Blacksmith's image ships it, GitHub's doesn't.
186-
- name: Install ripgrep
187-
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
188-
189187
# Runs the setup CLI's Bun tests plus each workspace's Vitest suite,
190188
# without `--coverage`. See the Codecov note below.
191189
- name: Run tests

0 commit comments

Comments
 (0)