Skip to main content

sharing

Framework-level sharing and privacy for user-authored resources (dashboards, documents, forms, decks, etc.). Use when making a resource table ownable, wiring list/read/update access checks, or dropping the standard share dialog into a template.

Source facts

Repository
BuilderIO/builder-agent-native-starter
Last source activity
November 6, 2026 at 01:33
Detected SKILL.md language
English
Stars
9
Forks
3

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
sharing
description
Framework-level sharing and privacy for user-authored resources (dashboards, documents, forms, decks, etc.). Use when making a resource table ownable, wiring list/read/update access checks, or dropping the standard share dialog into a template.
scope
dev
metadata
{"internal":true}
# Sharing — Private by Default, Explicit Share ## Rule Any resource a user **creates** (dashboards, documents, forms, decks, compositions, booking links, issues, analyses) is **private to the creator** by default and visible to others only when they have been **explicitly shared** with or when the creator changes visibility to `org` or `public`. This is the framework-level primitive. Every ownable resource gets it for free — same API, same UI, same skill. Workspace apps are the intentional organization-scoped exception: their default visibility is `org`, configurable by an organization owner/admin. The creator is stored as the app owner, and the creator or an organization owner/admin can change the app's visibility or manage its share grants. ## Concepts ### Three visibility levels - **`private`** — owner + explicit share grants only. Default. - **`org`** — owner + explicit grants + anyone in the same org. Members can view by default; `set-resource-visibility` with `role` lets them comment or edit. That role is stored as the share grant to the resource's own org, so it is removed when the resource leaves `org`. - **`public`** — owner + explicit grants + **anyone with the link**. They can view by default; `set-resource-visibility` with `role: "commenter"` lets signed-in people with the link comment (never edit). That role is a share row for the `link` principal, which is never listed or shared as a person, and it is removed when the resource leaves `public`. Public docs do NOT appear in other users' list/sidebar/search results — `accessFilter` omits them by default. They're reachable by id (`resolveAccess` admits them) so direct links and SSR routes like `/p/:id` keep working. If a list endpoint legitimately needs cross-user public discovery (a template gallery, etc.), pass `accessFilter(table, shares, ctx, minRole, { includePublic: true })`. Visibility is coarse. Explicit share grants are fine-grained (per user, reusable organization group, or per org). ### Roles on a share grant - **`viewer`** — read only. - **`commenter`** — read + add comments, but cannot edit the resource or manage shares. - **`editor`** — read + write. - **`admin`** — read + write + manage shares. Does NOT replace the single `owner_email` on the resource. Share roles apply to one resource; organization roles apply to a team; app roles apply within one app. These are separate permissions. For role setup, use `agent-native-docs` to find the current organization and app-role guide. Before adding an admin gate, identify which role system protects that operation and use its existing role descriptor and permission checks. Do not replace role checks with an email comparison or a custom `isAdmin` column. ### Anonymous public URLs stay separate Form "publish" slugs, booking-link slugs, any feature that exposes a URL to unauthenticated users — these are a different axis and are NOT controlled by the sharing system. Keep them alongside it. ## Make a resource ownable In your template's `server/db/schema.ts`: ```ts import { sql } from "drizzle-orm"; import { pgTable, text } from "drizzle-orm/pg-core"; import { createSharesTable, ownableColumns, } from "@agent-native/core/db/schema"; export const decks = pgTable("decks", { id: text("id").primaryKey(), title: text("title").notNull(), data: text("data").notNull(), createdAt: text("created_at").notNull().default(sql`now()`), updatedAt: text("updated_at").notNull().default(sql`now()`), ...ownableColumns(), // adds owner_email, org_id, visibility }); export const deckShares = createSharesTable("deck_shares"); ``` Then register it **in `server/db/index.ts`** (not the schema file — keeps the schema file free of the `getDb` closure and avoids circular imports): ```ts // server/db/index.ts import * as schema from "./schema.js"; import { createGetDb } from "@agent-native/core/db"; import { registerShareableResource } from "@agent-native/core/sharing"; export const getDb = createGetDb(schema); export { schema }; registerShareableResource({ type: "deck", resourceTable: schema.decks, sharesTable: schema.deckShares, displayName: "Deck", titleColumn: "title", getResourcePath: (deck) => `/deck/${deck.id}`, getDb, }); ``` Add `agentRead: { actions: ["get-deck"] }` naming the MCP tool that reads one resource, so the share panel can send it to Claude, Claude Code and Codex. The `type` string is the stable id the UI and actions use. `getDb` is required — the framework-level share actions use it to reach your template's DB. ### Restricting public visibility and cross-org user shares Some resources should NOT be reachable by an arbitrary authenticated user even with the link, and should NOT be shareable to an email outside the org. Two optional registration flags lock these axes down: ```ts registerShareableResource({ type: "extension", // ... allowPublic: false, // hides "Public" in the share dialog and rejects it server-side requireOrgMemberForUserShares: true, // user shares must target an org member or pending invitee supportsGroupShares: true, // enable reusable organization groups for this resource }); ``` - **`allowPublic: false`** — `set-resource-visibility('public')` throws `ForbiddenError`, `accessFilter` / `resolveAccess` treat any stored `'public'` row as private (defense in depth against bad data), and the share popover hides the "Public" option. `list-resource-shares` returns `policy.allowPublic: false` so the UI follows the server. - **`requireOrgMemberForUserShares: true`** — `share-resource` looks up `principalId` in `org_members` and `org_invitations` (pending) for the resource's `orgId` and rejects user shares to anyone else. The same flag also pins `principalType: "org"` shares to the resource's own org — sharing to a *different* org would let that org's members run code in the viewer's auth context (same threat model as a public extension). (The flag name is kept for backward compatibility; treat it as "lock both user and org shares to the resource's org".) Use both for resources that execute code or expose privileged data with the *viewer's* credentials. A public or cross-org-shared resource with that access could let an unrelated user act with the viewer's credentials. Defaults match historical behaviour: `allowPublic: true`, `requireOrgMemberForUserShares: false`. Resources that don't set the flags work as before. ### Reusable organization groups The workspace organization model stores reusable groups in `workspace_user_groups`. Organization owners/admins manage the groups and their members through the existing group actions. Set `supportsGroupShares: true` on a resource registration to enable `principalType: "group"` in the generic share actions and the shared popover's autocomplete. Group ids are always checked against the resource's organization, and membership is evaluated at access time so group changes apply to existing shares. Workspace apps opt into this flag and use the organization default visibility setting. The app creator is the durable `owner_email`, while organization owners/admins receive admin access through the registration's `canManageAccess` callback. For these registrations, a direct resource-level `admin` share does not override that manager policy; it grants the editor role for access checks, while only the creator and callback-approved managers can change visibility or manage share grants. ## Filter list/read queries ```ts import { accessFilter } from "@agent-native/core/sharing"; const rows = await db .select() .from(schema.decks) .where(accessFilter(schema.decks, schema.deckShares)); ``` `accessFilter` admits rows the current user owns, has been shared on, or that the user can reach via `org` visibility. `public` rows are NOT admitted by default — see the visibility section above for why and how to opt in. ## Guard write actions ```ts import { assertAccess } from "@agent-native/core/sharing"; export default defineAction({ schema: z.object({ id: z.string(), title: z.string() }), run: async (args) => { await assertAccess("deck", args.id, "editor"); // ...proceed }, }); ``` For delete actions use `"admin"` (or fold in `"owner"` to require the real owner). `authorize` is a different axis, not an alternative: it gates whether the caller may run the operation at all, while `assertAccess` scopes which row they may touch. A write action restricted to some teammates needs both — `authorize: appAccess.requireAny(...)` on the action, `assertAccess` inside `run`. ## Check many resources at once Never call `resolveAccess` in a loop over a list: each call runs several queries, so a page that checks every row pays that per row. When rows come from somewhere `accessFilter` cannot reach (a manifest, a gateway response), use `resolveAccessMany("deck", ids, ctx, { skipResourceBody: true })`. It returns a `Map` of the ids the viewer can open, makes the same decision as `resolveAccess`, and reads rows, shares, org membership, and group membership once. Give a registration that uses `canManageAccess` a `canManageAccessKey` (for example `resource.orgId`) so the manager check is asked once per key, not once per row. ## Create actions must set owner When inserting a new row, fill `ownerEmail` and `orgId` from the request context: ```ts import { fail } from "@agent-native/core/action"; import { getRequestUserEmail, getRequestOrgId, } from "@agent-native/core/server/request-context"; const ownerEmail = getRequestUserEmail(); // Never fall back to a shared sentinel owner; require an authenticated session. if (!ownerEmail) fail("Not authenticated", { statusCode: 401 }); await db.insert(schema.decks).values({ id: crypto.randomUUID(), title, data, ownerEmail, orgId: getRequestOrgId(), // visibility defaults to 'private' // ... }); ``` ## Drop in the share UI ```tsx import { ShareButton } from "@agent-native/toolkit/app/sharing"; // In the resource's header/toolbar: <ShareButton resourceType="deck" resourceId={deck.id} resourceTitle={deck.title} />; ``` For list views, show `<VisibilityBadge visibility={row.visibility} />` next to each resource. ## Standard share surface All app share popovers use one single-panel surface, top to bottom: 1. The invite field: one input for people, organization groups, and the org's agents, a role picker, and Invite. 2. People with access: the owner, people, groups, and agents in one list. Roles read "Can view", "Can comment", "Can edit", "Can manage access"; Remove access lives in each row's role menu. 3. General access: Only people invited / Anyone at <org> / Anyone with the link. Anyone at <org> has a role picker (Can view / Can comment / Can edit) and Anyone with the link has one (Can view / Can comment); the grants behind them are not listed under People. Changes save at once; only a type registered with `confirmOrgVisibility: true` (chats) asks before going from private to the organization. 4. A footer with Copy link (`shareUrl`) and a Send to menu. Rules: - Use the text-only `ShareTrigger` from `@agent-native/toolkit/sharing`. - Never split people and agents into separate tabs or sections. Agents that can hold their own access are org service principals (`svc-<name>@service.<orgId>`); `list-resource-shares` returns them as `agents` for managers, and they are shared as ordinary `user` principals. Agents that connect over MCP or OAuth act as the person who connected them, so they get no row: put ways to hand the resource to them in Send to. - Send to (`sendTo`) holds every handoff: `openInAgent` (Claude, Claude Code, Codex), resource-specific `items`, `copyAgentPrompt`, and `openMcpSettings`. Do not add another agent panel or tab. - Declare `agentRead: { actions: ["get-<thing>"] }` (plus `idParam` when the tool's id argument is not `id`) on the registration. The panel then builds Open in Claude, Claude Code and Codex and Copy agent prompt itself: the prompt sends the agent to this app's MCP server to call that tool with the person's own permissions. Pass `sendTo.openInAgent` only when the app needs a different prompt. Agent-readable resources also get Copy agent link. - When `supportsGroupShares` is enabled, groups come from the same invite field's autocomplete. Do not add a separate group-management surface. - App-specific tabs (`shareTabs`) may remain when they represent a real domain action (for example, an embed-code editor). ## Actions available everywhere The framework auto-mounts these actions in every template — no per-template boilerplate: | Action | Args | Purpose | | -------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- | | `share-resource` | `resourceType, resourceId, principalType, principalId, role, notify?, resourceUrl?, message?` | Grant a user, group, or org access. `notify` defaults to true for individual user shares; `resourceUrl` can provide the direct app link and `message` an optional short note for the notification email. | | `unshare-resource` | `resourceType, resourceId, principalType, principalId` | Revoke access. | | `list-resource-shares` | `resourceType, resourceId` | Current visibility + all share grants. | | `set-resource-visibility` | `resourceType, resourceId` (or `resourceIds`, up to 100), `visibility`, `role?` | Change to `private` / `org` / `public`. With `org`, `role` (`viewer` / `commenter` / `editor`) sets what members can do; with `public`, `role` (`viewer` / `commenter`) sets what signed-in people with the link can do. With `resourceIds`, every resource is checked first and none change if one can't; one approval covers the call. | Both the agent and the UI use these same actions. The agent calls them as tools; UI code should use `ShareButton` / `ShareDialog` or the action client hooks instead of hand-writing route calls. To re-skin the share notification email for the whole app, register `overrideTransactionalEmail("core.resource-shared", render)` from a server plugin. Do not fork `share-resource` or add another per-resource hook: the registration's `getBrandName` / `getLogoUrl` / `getSender` / `getHeroHtml` / `getShareEmailExtras` hooks still feed the default template and the override's props. See `/docs/deployment#email-templates`. ## Migration pattern for existing tables When retrofitting an existing resource table: 1. Add `owner_email`, `org_id`, `visibility` columns (defaults `'local@localhost'`, `NULL`, `'private'`). 2. Backfill `owner_email` from any prior creator trail; otherwise leave the default. 3. Add the companion `{type}_shares` table. 4. Register via `registerShareableResource`. 5. Update list/read actions to use `accessFilter`. 6. Update update/delete actions to `assertAccess` with the correct role. 7. Add `<ShareButton>` to the resource header. 8. Add `getResourcePath` in the registration so agent-triggered shares can email a direct link even when no UI supplied `resourceUrl`. ## Templates that opt out Sharing doesn't apply to: - **Personal-data apps** (mail) — user-scoped by design. - **External source-of-truth data** — apply the access policy enforced by the upstream system. - **Demo/boilerplate** (starter) — no resources. For these, add a short note to the template's `AGENTS.md` explaining why. ## Debugging - `ForbiddenError` from an action means the current user isn't owner / hasn't been shared / can't meet the role bar. - If the agent can't see a resource it just created, check that the insert actually set `owner_email` from the request context. - If a share doesn't take effect in the UI, confirm the template's `list-*` action uses `accessFilter` — the share rows are there but nothing is reading them yet.
View on GitHub