sharing

$npx mdskill add BuilderIO/agent-native/sharing

Enforce private-by-default sharing with explicit controls.

  • Protects user resources from unauthorized access.
  • Integrates with dashboards, documents, forms, and decks.
  • Uses visibility levels and explicit share grants.
  • Delivers access via standard API and UI templates.
SKILL.md
.github/skills/sharingView on GitHub ↗
---
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.
---

# 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.

## Concepts

### Three visibility levels

- **`private`** — owner + explicit share grants only. Default.
- **`org`** — owner + explicit grants + anyone in the same org (read-only).
- **`public`** — owner + explicit grants + **anyone with the link** (read-only). 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 or per org).

### Roles on a share grant

- **`viewer`** — read only.
- **`editor`** — read + write.
- **`admin`** — read + write + manage shares. Does NOT replace the single `owner_email` on the resource.

### 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 {
  table,
  text,
  integer,
  now,
  ownableColumns,
  createSharesTable,
} from "@agent-native/core/db/schema";

export const decks = table("decks", {
  id: text("id").primaryKey(),
  title: text("title").notNull(),
  data: text("data").notNull(),
  createdAt: text("created_at").notNull().default(now()),
  updatedAt: text("updated_at").notNull().default(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",
  getDb,
});
```

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.

## 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).

## Create actions must set owner

When inserting a new row, fill `ownerEmail` and `orgId` from the request context:

```ts
import {
  getRequestUserEmail,
  getRequestOrgId,
} from "@agent-native/core/server/request-context";

await db.insert(schema.decks).values({
  id: nanoid(),
  title,
  data,
  ownerEmail: getRequestUserEmail() ?? "local@localhost",
  orgId: getRequestOrgId(),
  // visibility defaults to 'private'
  // ...
});
```

## Drop in the share UI

```tsx
import { ShareButton } from "@agent-native/core/client";

// 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.

## 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`                   | Grant a user or org access.               |
| `unshare-resource`         | `resourceType, resourceId, principalType, principalId`                         | Revoke access.                            |
| `list-resource-shares`     | `resourceType, resourceId`                                                     | Current visibility + all share grants.    |
| `set-resource-visibility`  | `resourceType, resourceId, visibility`                                         | Change to `private` / `org` / `public`.  |

Both the agent and the UI call these via the same endpoints.

## 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.

## Templates that opt out

Sharing doesn't apply to:

- **Personal-data apps** (mail, macros) — user-scoped by design.
- **External source-of-truth apps** (issues → Jira, recruiting → Greenhouse) — ACL lives in the upstream system.
- **Demo/boilerplate** (starter) — no resources.

For these, add a short note to the template's `AGENTS.md` explaining why.

## Analytics (follow-up)

Dashboards and analyses in the `analytics` template currently live in the settings KV store (`u:<email>:dashboard-*` keys), not SQL. Sharing requires either migrating them to SQL tables (then applying this skill) or extending the settings store with a parallel share overlay. This is a tracked follow-up — see the analytics template's `AGENTS.md`.

## 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.
More from BuilderIO/agent-native