diff --git a/packages/core/src/org/context.ts b/packages/core/src/org/context.ts index 9510e3a0e..594fcca66 100644 --- a/packages/core/src/org/context.ts +++ b/packages/core/src/org/context.ts @@ -127,6 +127,46 @@ export async function resolveOrgIdForEmail( } } +/** + * Create a new organization and add the caller as a member with the given + * role. Generates a per-org A2A secret for cross-app delegation and writes + * the caller's `active-org-id` user-setting so the new org is immediately + * active. + * + */ +export async function createOrganization( + name: string, + email: string, + role: OrgRole = "owner", +): Promise<{ + id: string; + name: string; + role: OrgRole; + a2aSecret: string; + createdAt: number; +}> { + const trimmedName = name.trim(); + const exec = getDbExec(); + const id = nanoid(); + const createdAt = Date.now(); + const { randomBytes } = await import("node:crypto"); + const a2aSecret = randomBytes(32).toString("base64url"); + + await exec.execute({ + sql: `INSERT INTO organizations (id, name, created_by, created_at, a2a_secret) VALUES (?, ?, ?, ?, ?)`, + args: [id, trimmedName, email, createdAt, a2aSecret], + }); + + await exec.execute({ + sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`, + args: [nanoid(), id, email, role, createdAt], + }); + + await putUserSetting(email, "active-org-id", { orgId: id }); + + return { id, name: trimmedName, role, a2aSecret, createdAt }; +} + function defaultOrgName( email: string, session: { name?: string } | null, diff --git a/packages/core/src/org/handlers.ts b/packages/core/src/org/handlers.ts index 90e4608cc..a94dd4a56 100644 --- a/packages/core/src/org/handlers.ts +++ b/packages/core/src/org/handlers.ts @@ -43,7 +43,7 @@ import { getDbExec } from "../db/client.js"; import { sendEmail, isEmailConfigured } from "../server/email.js"; import { renderInviteEmail } from "../server/email-templates.js"; import { getAppProductionUrl } from "../server/app-url.js"; -import { getOrgContext } from "./context.js"; +import { getOrgContext, createOrganization } from "./context.js"; import { isFreeEmailProvider } from "./free-email-providers.js"; import type { OrgRole } from "./types.js"; @@ -180,27 +180,8 @@ export const createOrgHandler = defineEventHandler(async (event: H3Event) => { }); } - const orgId = nanoid(); - const now = Date.now(); - const e = await exec(); - - // Auto-generate a per-org A2A secret for cross-app delegation - const { randomBytes } = await import("node:crypto"); - const a2aSecret = randomBytes(32).toString("base64url"); - - await e.execute({ - sql: `INSERT INTO organizations (id, name, created_by, created_at, a2a_secret) VALUES (?, ?, ?, ?, ?)`, - args: [orgId, name, email, now, a2aSecret], - }); - - await e.execute({ - sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`, - args: [nanoid(), orgId, email, "owner", now], - }); - - await putUserSetting(email, "active-org-id", { orgId }); - - return { id: orgId, name, role: "owner" }; + const { id, name: createdName, role } = await createOrganization(name, email); + return { id, name: createdName, role }; }); /** GET /_agent-native/org/members — list org members */ diff --git a/packages/core/src/org/index.ts b/packages/core/src/org/index.ts index b35b751ab..204703435 100644 --- a/packages/core/src/org/index.ts +++ b/packages/core/src/org/index.ts @@ -17,6 +17,7 @@ export { getA2ASecretByDomain, resolveOrgByDomain, resolveOrgIdForEmail, + createOrganization, } from "./context.js"; export { acceptPendingInvitationsForEmail } from "./accept-pending.js"; diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts index 5d4388d01..0348f339b 100644 --- a/packages/core/src/server/auth.spec.ts +++ b/packages/core/src/server/auth.spec.ts @@ -137,7 +137,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -169,7 +168,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -205,7 +203,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -666,7 +663,6 @@ describe("server/auth", () => { signInEmail, signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -776,7 +772,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -826,7 +821,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), @@ -882,7 +876,6 @@ describe("server/auth", () => { signInEmail: vi.fn(), signUpEmail: vi.fn(), signOut: vi.fn(), - listOrganizations: vi.fn(), }, })), getBetterAuthSync: vi.fn(() => undefined), diff --git a/packages/core/src/server/better-auth-instance.ts b/packages/core/src/server/better-auth-instance.ts index 136ba8809..2f662c678 100644 --- a/packages/core/src/server/better-auth-instance.ts +++ b/packages/core/src/server/better-auth-instance.ts @@ -10,7 +10,6 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { betterAuth, type BetterAuthOptions } from "better-auth"; -import { organization } from "better-auth/plugins/organization"; import { jwt } from "better-auth/plugins/jwt"; import { bearer } from "better-auth/plugins/bearer"; import { sendEmail, isEmailConfigured } from "./email.js"; @@ -196,7 +195,6 @@ export interface BetterAuthInstance { id: string; token: string; expiresAt: Date; - activeOrganizationId?: string; }; } | null>; signInEmail: (opts: { @@ -211,7 +209,6 @@ export interface BetterAuthInstance { }; }) => Promise; signOut: (opts: { headers: Headers }) => Promise; - listOrganizations: (opts: { headers: Headers }) => Promise; }; } @@ -817,8 +814,6 @@ async function createBetterAuthInstance( : {}), }, plugins: [ - // Organizations: many:many user:org, roles, invitations - organization(), // JWT: issue tokens for A2A calls, JWKS endpoint for verification jwt({ jwt: { diff --git a/templates/clips/AGENTS.md b/templates/clips/AGENTS.md index a492f8e6e..777dc6a06 100644 --- a/templates/clips/AGENTS.md +++ b/templates/clips/AGENTS.md @@ -65,28 +65,28 @@ Resources are SQL-backed persistent files for notes, learnings, and context. All structured data lives in SQL via Drizzle ORM — **dialect-agnostic** (Neon Postgres in production, SQLite for local). See `server/db/schema.ts` for full column definitions. This is the summary: -Team / tenant data lives in the framework's better-auth `organization` tables. Clips-specific data (spaces, folders, recordings, etc.) hangs off `organization_id` FKs. - -| Table | Holds | -| ---------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `organization` (better-auth) | Team. Name, slug, logo. Managed by the framework — created via better-auth (or the `create-organization` action). | -| `member` (better-auth) | Who belongs to each org and their role (`owner` / `admin` / `member`). | -| `invitation` (better-auth) | Pending org invites. | -| `organization_settings` | Clips-specific org sidecar: `brand_color`, `brand_logo_url`, `default_visibility`. Keyed by `organization.id`. | -| `spaces` | Topic spaces inside an org (engineering, design, etc.). FK: `organization_id`. | -| `space_members` | Who can see/post to each space. | -| `folders` | Library folders (nest via `parent_id`, scoped to space or personal). FK: `organization_id`. | -| `recordings` | The core resource. Title, video URL, duration, status, edits JSON, etc. FK: `organization_id`. | -| `recording_shares` | Per-user / per-org share grants via framework `sharing`. | -| `recording_tags` | Free-form tags. FK: `organization_id`. | -| `recording_transcripts` | Whisper output — segments JSON + fullText + status. | -| `recording_ctas` | Call-to-action buttons (label, URL, placement). | -| `recording_comments` | Threaded comments with `video_timestamp_ms` + emoji reactions JSON. FK: `organization_id`. | -| `recording_reactions` | Emoji reactions tied to a video timestamp. | -| `recording_viewers` | One row per viewer: watch total, completed %, whether the view counted. | -| `recording_events` | Granular events: view-start, watch-progress, seek, pause, cta-click, etc. | - -> Older schemas had Clips-specific `workspaces` / `workspace_members` / `invites` tables. Those have been **replaced** by better-auth's `organization` / `member` / `invitation` tables — any references you see to "workspace" in older code or data are deprecated aliases for "organization". +Team / tenant data lives in the framework's `organizations` / `org_members` / `org_invitations` tables (managed by `packages/core/src/org/`). Clips-specific data (spaces, folders, recordings, etc.) hangs off `organization_id` FKs that hold those org ids. + +| Table | Holds | +| ----------------------- | --------------------------------------------------------------------------------------------------------------- | +| `organizations` | Team. `id`, `name`, `created_by`, `created_at`. Created via the `create-organization` action. | +| `org_members` | Who belongs to each org and their role (`owner` / `admin` / `member`). Email-keyed. | +| `org_invitations` | Pending org invites. Email-keyed. No token expiry. | +| `organization_settings` | Clips-specific org sidecar: `brand_color`, `brand_logo_url`, `default_visibility`. Keyed by `organizations.id`. | +| `spaces` | Topic spaces inside an org (engineering, design, etc.). FK: `organization_id`. | +| `space_members` | Who can see/post to each space. | +| `folders` | Library folders (nest via `parent_id`, scoped to space or personal). FK: `organization_id`. | +| `recordings` | The core resource. Title, video URL, duration, status, edits JSON, etc. FK: `organization_id`. | +| `recording_shares` | Per-user / per-org share grants via framework `sharing`. | +| `recording_tags` | Free-form tags. FK: `organization_id`. | +| `recording_transcripts` | Whisper output — segments JSON + fullText + status. | +| `recording_ctas` | Call-to-action buttons (label, URL, placement). | +| `recording_comments` | Threaded comments with `video_timestamp_ms` + emoji reactions JSON. FK: `organization_id`. | +| `recording_reactions` | Emoji reactions tied to a video timestamp. | +| `recording_viewers` | One row per viewer: watch total, completed %, whether the view counted. | +| `recording_events` | Granular events: view-start, watch-progress, seek, pause, cta-click, etc. | + +> Older schemas had Clips-specific `workspaces` / `workspace_members` / `invites` tables. Those have been **replaced** by the framework's `organizations` / `org_members` / `org_invitations` tables — any references you see to "workspace" in older code or data are deprecated aliases for "organization". The `server/plugins/db.ts` sync copies any pre-existing workspace rows into the framework tables on boot. Visibility and sharing use the framework `sharing` system — recordings are registered as a shareable resource in `server/db/index.ts` via `registerShareableResource({ type: "recording", ... })`. Use the auto-mounted `share-resource` / `set-resource-visibility` / `list-resource-shares` actions (see Sharing below). Password and `expiresAt` are **extra** privacy controls on top of framework visibility — they're in the `recordings` table. @@ -105,7 +105,7 @@ Ephemeral UI state lives in `application_state`, accessed via `readAppState(key) | `editor-draft` | In-progress non-destructive edits for the recording being edited | Bidirectional | | `selection` | User's current text selection inside transcript or comment | UI -> Agent (read-only) | -> Active organization lives in the better-auth session (`session.activeOrganizationId`), **not** in application state. An older `current-workspace` app-state key is deprecated. To switch orgs, use `useSwitchOrg()` on the client or better-auth's `setActiveOrganization` API. The previous session's active org is restored automatically on login. +> Active organization lives in the per-user `active-org-id` user-setting, **not** in application state. An older `current-workspace` app-state key is deprecated. To switch orgs, use `useSwitchOrg().mutate({ organizationId })` on the client (which hits `PUT /_agent-native/org/switch`) or `putUserSetting(email, "active-org-id", { orgId })` server-side. The framework's `getOrgContext(event)` resolves this on every request. ### Navigation state shape @@ -128,33 +128,33 @@ Views: `library`, `spaces`, `space`, `archive`, `trash`, `record`, `recording`, ## Common Tasks -| User request | What to do | -| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| "What am I looking at?" | `pnpm action view-screen` | -| "Start a screen recording" | `pnpm action navigate --view=record` — then the user picks a mode and hits Start. Recording is a UI gesture (MediaRecorder needs user consent) — see Rule 10. | -| "Stop recording" | Stop is a UI gesture. Users press the stop button in the recording toolbar. | -| "Rename this recording to 'Onboarding walkthrough'" | `pnpm action update-recording --id= --title="Onboarding walkthrough"` | -| "Write me a title" | Read transcript via `get-recording-player-data --recordingId=`, then `update-recording --id= --title="..."` | -| "Write me a description/summary" | Read transcript via `get-recording-player-data --recordingId=`, then `update-recording --id= --description="..."` | -| "Add chapters to this video" | Read transcript, then `set-chapters --recordingId= --chapters='[{"startMs":0,"title":"Intro"},...]'` | -| "Remove the filler words" | `pnpm action remove-filler-words --recordingId=` (appends proposed trims into `editsJson`) | -| "Remove silences" | `pnpm action remove-silences --recordingId= [--thresholdMs=500]` | -| "Find the part where I talk about pricing" | Read `get-recording-player-data --recordingId=` and grep the transcript segments for the term. | -| "Share this with alice@example.com as viewer" | Call the auto-mounted `share-resource` action with `resourceType=recording`, `resourceId=`, `principalType=user`, `principalId=alice@example.com`, and `role=viewer` | -| "Make this public" | Call the auto-mounted `set-resource-visibility` action with `resourceType=recording`, `resourceId=`, and `visibility=public` | -| "Add a password to this share" | `pnpm action update-recording --id= --password=` | -| "Set this to expire in 7 days" | `pnpm action update-recording --id= --expiresAt=` | -| "Trim the first 30 seconds" | `pnpm action trim-recording --recordingId= --startMs=0 --endMs=30000` | -| "Split this at the current playhead" | Read `player-state` for `currentMs`, then `split-recording --recordingId= --atMs=` | -| "Move this recording to my 'Design Reviews' folder" | Look up folder id via `list-organization-state`, then `update-recording --id= --folderId=` (or `move-recording --id= --folderId=`) | -| "Archive this" | `pnpm action archive-recording --id=` | -| "Delete this" | `pnpm action trash-recording --id=` | -| "Show me my most-watched recordings" | `pnpm action list-recordings --sort=views --limit=10` | -| "Who watched this?" | `pnpm action list-viewers --recordingId=` | -| "Reply to the comment at 1:23" | Use `list-comments --recordingId=` to find the thread, then `add-comment --recordingId= --threadId= --content="..."` | -| "Give me a share link" | The public share link is `/share/` and the embed is `/embed/`. Make sure visibility is `public` via `set-resource-visibility` if needed. | -| "Switch to the Product organization" | Use `list-organization-state` to find the org id, then on the client call `useSwitchOrg().mutate({ organizationId })` (or better-auth's `setActiveOrganization`). There is no `set-current-workspace` action anymore. | -| "Rename this organization" | Use better-auth's organization-update API, or `pnpm action set-organization-branding` for brand color / logo tweaks. | +| User request | What to do | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| "What am I looking at?" | `pnpm action view-screen` | +| "Start a screen recording" | `pnpm action navigate --view=record` — then the user picks a mode and hits Start. Recording is a UI gesture (MediaRecorder needs user consent) — see Rule 10. | +| "Stop recording" | Stop is a UI gesture. Users press the stop button in the recording toolbar. | +| "Rename this recording to 'Onboarding walkthrough'" | `pnpm action update-recording --id= --title="Onboarding walkthrough"` | +| "Write me a title" | Read transcript via `get-recording-player-data --recordingId=`, then `update-recording --id= --title="..."` | +| "Write me a description/summary" | Read transcript via `get-recording-player-data --recordingId=`, then `update-recording --id= --description="..."` | +| "Add chapters to this video" | Read transcript, then `set-chapters --recordingId= --chapters='[{"startMs":0,"title":"Intro"},...]'` | +| "Remove the filler words" | `pnpm action remove-filler-words --recordingId=` (appends proposed trims into `editsJson`) | +| "Remove silences" | `pnpm action remove-silences --recordingId= [--thresholdMs=500]` | +| "Find the part where I talk about pricing" | Read `get-recording-player-data --recordingId=` and grep the transcript segments for the term. | +| "Share this with alice@example.com as viewer" | Call the auto-mounted `share-resource` action with `resourceType=recording`, `resourceId=`, `principalType=user`, `principalId=alice@example.com`, and `role=viewer` | +| "Make this public" | Call the auto-mounted `set-resource-visibility` action with `resourceType=recording`, `resourceId=`, and `visibility=public` | +| "Add a password to this share" | `pnpm action update-recording --id= --password=` | +| "Set this to expire in 7 days" | `pnpm action update-recording --id= --expiresAt=` | +| "Trim the first 30 seconds" | `pnpm action trim-recording --recordingId= --startMs=0 --endMs=30000` | +| "Split this at the current playhead" | Read `player-state` for `currentMs`, then `split-recording --recordingId= --atMs=` | +| "Move this recording to my 'Design Reviews' folder" | Look up folder id via `list-organization-state`, then `update-recording --id= --folderId=` (or `move-recording --id= --folderId=`) | +| "Archive this" | `pnpm action archive-recording --id=` | +| "Delete this" | `pnpm action trash-recording --id=` | +| "Show me my most-watched recordings" | `pnpm action list-recordings --sort=views --limit=10` | +| "Who watched this?" | `pnpm action list-viewers --recordingId=` | +| "Reply to the comment at 1:23" | Use `list-comments --recordingId=` to find the thread, then `add-comment --recordingId= --threadId= --content="..."` | +| "Give me a share link" | The public share link is `/share/` and the embed is `/embed/`. Make sure visibility is `public` via `set-resource-visibility` if needed. | +| "Switch to the Product organization" | Use `list-organization-state` to find the org id, then on the client call `useSwitchOrg().mutate({ organizationId })`. Server-side: `putUserSetting(email, "active-org-id", { orgId })`. There is no `set-current-workspace` action anymore. | +| "Rename this organization" | Use the framework `PATCH /_agent-native/org` endpoint (via `useUpdateOrg()` on the client), or `pnpm action set-organization-branding` for brand color / logo tweaks. | After any recording mutation (rename, move, edit, archive, delete, add comment, etc.) the actions trigger a UI refresh automatically via `refresh-signal`. @@ -282,21 +282,21 @@ Granular per-event recording (view-start / watch-progress / seek / pause / cta-c ### Organization + invites -Teams in Clips are better-auth organizations. Membership, roles, and invitations live on the framework `organization` / `member` / `invitation` tables. The actions below are thin Clips-specific wrappers that operate on those tables. +Teams in Clips use the framework's `organizations` / `org_members` / `org_invitations` tables. The actions below are thin Clips-specific wrappers that operate on those tables (roster, branding sidecar, invites). For framework-wide org operations — rename, switch active org, set domain — use the `/_agent-native/org/*` endpoints + `useOrg()` / `useSwitchOrg()` hooks directly. -| Action | Args | Purpose | -| --------------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `list-organization-state` | | Roster + spaces + folders summary for the active organization. | -| `create-organization` | `--name [--slug] [--brandColor]` | Create a new organization (delegates to better-auth, seeds `organization_settings`). | -| `set-organization-branding` | `--brandColor [--brandLogoUrl]` | Update the active organization's `organization_settings` row (brand color / logo). | -| `invite-member` | `--email [--role admin\|member]` | Send an invite. Roles use better-auth's `admin` / `member` model. Legacy Clips roles (`viewer`, `creator-lite`, `creator`) are still accepted by the action and map to `member`. | -| `update-member-role` | `--email --role ` | Change an existing member's role. | -| `remove-member` | `--email ` | Remove a member from the organization. | -| `get-invite` | `--token ` | Look up a pending invite. | -| `accept-invite` | `--token ` | Accept a pending invite. | -| `decline-invite` | `--token ` | Decline a pending invite. | +| Action | Args | Purpose | +| --------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `list-organization-state` | | Roster + spaces + folders summary for the active organization. | +| `create-organization` | `--name ` | Create a new organization, add the caller as owner, seed `organization_settings`, and activate it. | +| `set-organization-branding` | `--brandColor [--brandLogoUrl]` | Update the active organization's `organization_settings` row (brand color / logo). | +| `invite-member` | `--email [--role admin\|member]` | Send an invite. Roles collapse to `admin` / `member`. Legacy Clips roles (`viewer`, `creator-lite`, `creator`) are still accepted and map to `member`. | +| `update-member-role` | `--email --role ` | Change an existing member's role. Cannot change the owner's role. | +| `remove-member` | `--email ` | Remove a member from the organization. Cannot remove the owner. | +| `get-invite` | `--token ` | Look up a pending invite. | +| `accept-invite` | `--token ` | Accept a pending invite. Activates the org for the caller via the `active-org-id` user-setting. | +| `decline-invite` | `--token ` | Decline a pending invite. | -> **Switching orgs.** There is no `set-current-workspace` action — the active org lives in the better-auth session. From the client use `useSwitchOrg().mutate({ organizationId })`; server-side use better-auth's `setActiveOrganization` API. +> **Switching orgs.** There is no `set-current-workspace` action — the active org lives in the per-user `active-org-id` user-setting. From the client use `useSwitchOrg().mutate({ organizationId })` (which hits `PUT /_agent-native/org/switch`); server-side, `putUserSetting(email, "active-org-id", { orgId })`. The framework's `getOrgContext(event)` resolves this on every request. ### Navigation + context @@ -379,7 +379,7 @@ All standard CRUD (list, get, create, update) goes through `/_agent-native/actio ## Authentication -This template uses the framework's default auth — Better Auth, with email/password and optional Google / GitHub social providers. Better Auth's organization plugin owns the team primitives (`organization` / `member` / `invitation` tables, see [Team & Recordings Data Model](#team--recordings-data-model)). Use `getSession(event)` server-side and `useSession()` client-side; per-user scoping inside actions / handlers reads `getRequestUserEmail()` from `@agent-native/core/server/request-context`. +This template uses the framework's default auth — Better Auth, with email/password and optional Google / GitHub social providers. Team primitives live in the framework's own `organizations` / `org_members` / `org_invitations` tables (see [Data Sources](#data-sources)) — not in Better Auth's organization tables. Use `getSession(event)` server-side and `useSession()` client-side; per-user scoping inside actions / handlers reads `getRequestUserEmail()` from `@agent-native/core/server/request-context`, and per-org scoping reads `getOrgContext(event)` from `@agent-native/core/org`. See the `authentication` skill for the full mode matrix (`AUTH_MODE=local`, `ACCESS_TOKEN`, `AUTH_DISABLED`, BYOA) and the `security` skill for the access-control model (`ownableColumns`, `accessFilter`, `assertAccess`). diff --git a/templates/clips/actions/accept-invite.ts b/templates/clips/actions/accept-invite.ts index 07f554d29..4f489e5a9 100644 --- a/templates/clips/actions/accept-invite.ts +++ b/templates/clips/actions/accept-invite.ts @@ -1,9 +1,9 @@ /** * Accept an organization invite. * - * Verifies the invitation exists and is pending, inserts a row into the - * better-auth `member` table for the current user, and marks the invitation - * as accepted. Writes `refresh-signal` so the UI refetches lists. + * Verifies the invitation is pending, inserts a row into `org_members` for + * the current user, marks the invitation as accepted, and activates the new + * org for the caller via the `active-org-id` user-setting. * * Usage: * pnpm action accept-invite --token= @@ -11,56 +11,32 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; +import { putUserSetting } from "@agent-native/core/settings"; import { z } from "zod"; import { getCurrentOwnerEmail, nanoid } from "../server/lib/recordings.js"; interface InvitationRow { id: string; - organization_id: string; + org_id: string; email: string | null; role: string | null; status: string | null; - expires_at: string | number | null; -} - -function expiresInPast(v: string | number | null): boolean { - if (v === null || v === undefined) return false; - if (typeof v === "number") return v < Date.now(); - if (/^\d+$/.test(String(v))) return Number(v) < Date.now(); - const t = new Date(v).getTime(); - return Number.isFinite(t) && t < Date.now(); -} - -async function resolveUserId(email: string): Promise { - const exec = getDbExec(); - try { - const sql = isPostgres() - ? `SELECT id FROM "user" WHERE email = $1 LIMIT 1` - : `SELECT id FROM user WHERE email = ? LIMIT 1`; - const res = await exec.execute({ sql, args: [email] }); - const row = (res.rows as Array<{ id?: string }>)[0]; - return row?.id ?? null; - } catch { - return null; - } } export default defineAction({ description: - "Accept an organization invite. Inserts a member row for the current user with the invited role and marks the invitation as accepted.", + "Accept an organization invite. Inserts an org_members row for the current user with the invited role, marks the invitation as accepted, and switches the caller into the new org.", schema: z.object({ token: z.string().min(1).describe("Invite token (invitation id)"), }), run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); const me = getCurrentOwnerEmail(); + const meLower = me.toLowerCase(); const inviteRes = await exec.execute({ - sql: pg - ? `SELECT id, organization_id, email, role, status, expires_at FROM invitation WHERE id = $1 LIMIT 1` - : `SELECT id, organization_id, email, role, status, expires_at FROM invitation WHERE id = ? LIMIT 1`, + sql: `SELECT id, org_id, email, role, status FROM org_invitations WHERE id = ? LIMIT 1`, args: [args.token], }); const invite = (inviteRes.rows as InvitationRow[])[0]; @@ -69,56 +45,36 @@ export default defineAction({ throw new Error("Invite already accepted."); if (invite.status === "rejected" || invite.status === "canceled") throw new Error("Invite is no longer valid."); - if (expiresInPast(invite.expires_at)) - throw new Error("Invite has expired."); - const userId = (await resolveUserId(me)) ?? me; const role: "admin" | "member" = invite.role === "admin" ? "admin" : "member"; const nowMs = Date.now(); - // Skip if the user already has a member row for this org. + // Skip insert if the user is already a member of this org. const existsRes = await exec.execute({ - sql: pg - ? `SELECT id FROM member WHERE organization_id = $1 AND user_id = $2 LIMIT 1` - : `SELECT id FROM member WHERE organization_id = ? AND user_id = ? LIMIT 1`, - args: [invite.organization_id, userId], + sql: `SELECT id FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, + args: [invite.org_id, meLower], }); if (!(existsRes.rows as any[]).length) { - const memberId = nanoid(); - if (pg) { - await exec.execute({ - sql: `INSERT INTO member (id, organization_id, user_id, role, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW())`, - args: [memberId, invite.organization_id, userId, role], - }); - } else { - await exec.execute({ - sql: `INSERT INTO member (id, organization_id, user_id, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, - args: [memberId, invite.organization_id, userId, role, nowMs, nowMs], - }); - } - } - - if (pg) { await exec.execute({ - sql: `UPDATE invitation SET status = 'accepted', updated_at = NOW() WHERE id = $1`, - args: [invite.id], - }); - } else { - await exec.execute({ - sql: `UPDATE invitation SET status = 'accepted', updated_at = ? WHERE id = ?`, - args: [nowMs, invite.id], + sql: `INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)`, + args: [nanoid(), invite.org_id, me, role, nowMs], }); } + await exec.execute({ + sql: `UPDATE org_invitations SET status = 'accepted' WHERE id = ?`, + args: [invite.id], + }); + + await putUserSetting(me, "active-org-id", { orgId: invite.org_id }); + await writeAppState("refresh-signal", { ts: Date.now() }); - console.log( - `Accepted invite for ${me} into organization ${invite.organization_id}`, - ); + console.log(`Accepted invite for ${me} into organization ${invite.org_id}`); return { - organizationId: invite.organization_id, + organizationId: invite.org_id, email: me, role, }; diff --git a/templates/clips/actions/create-organization.ts b/templates/clips/actions/create-organization.ts index 2dac1f8be..24c3cc6e7 100644 --- a/templates/clips/actions/create-organization.ts +++ b/templates/clips/actions/create-organization.ts @@ -1,119 +1,43 @@ /** * Create a new organization. * - * Inserts a better-auth `organization` row, adds the caller as an `admin` in - * `member`, and seeds a Clips-specific `organization_settings` sidecar row - * (default brand color and visibility). Returns the new org id so the client - * can switch to it via `setActiveOrganization`. + * Delegates the canonical org + member + active-org-setting writes to the + * framework `createOrganization` helper (caller becomes an `admin` in + * `org_members`). Then seeds a Clips-specific `organization_settings` + * sidecar row with default brand color and visibility. * * Usage: * pnpm action create-organization --name="Acme" - * pnpm action create-organization --name="Acme" --slug=acme */ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { createOrganization } from "@agent-native/core/org"; import { z } from "zod"; -import { getCurrentOwnerEmail, nanoid } from "../server/lib/recordings.js"; - -function slugify(input: string): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40); -} - -async function slugExists(slug: string): Promise { - const exec = getDbExec(); - const sql = isPostgres() - ? `SELECT id FROM organization WHERE slug = $1 LIMIT 1` - : `SELECT id FROM organization WHERE slug = ? LIMIT 1`; - const res = await exec.execute({ sql, args: [slug] }); - return (res.rows as any[]).length > 0; -} - -async function resolveUserId(email: string): Promise { - const exec = getDbExec(); - try { - const sql = isPostgres() - ? `SELECT id FROM "user" WHERE email = $1 LIMIT 1` - : `SELECT id FROM user WHERE email = ? LIMIT 1`; - const res = await exec.execute({ sql, args: [email] }); - const row = (res.rows as Array<{ id?: string }>)[0]; - return row?.id ?? null; - } catch { - return null; - } -} +import { getCurrentOwnerEmail } from "../server/lib/recordings.js"; export default defineAction({ description: - "Create a new organization and add the caller as an admin member. Seeds a Clips-specific organization_settings row with default brand color #18181B and private visibility. Returns the new organization id so the client can activate it via setActiveOrganization.", + "Create a new organization and add the caller as an admin member. Seeds a Clips-specific organization_settings row with default brand color #18181B and private visibility, then activates the new org for the caller. Returns the new organization id.", schema: z.object({ name: z.string().min(1).describe("Organization name"), - slug: z - .string() - .optional() - .describe( - "URL slug (lowercase, dashes). Auto-generated from the name when omitted.", - ), }), run: async (args) => { - const exec = getDbExec(); const ownerEmail = getCurrentOwnerEmail(); - const id = nanoid(); - const pg = isPostgres(); - const nowMs = Date.now(); - const nowIso = new Date(nowMs).toISOString(); - - // Resolve a unique slug — retry with a short suffix on collision. - const base = - slugify(args.slug || args.name) || `org-${id.slice(0, 6).toLowerCase()}`; - let slug = base; - for (let attempt = 0; attempt < 5; attempt++) { - if (!(await slugExists(slug))) break; - slug = `${base}-${nanoid(4).toLowerCase()}`; - } - - // Insert the better-auth organization row. - if (pg) { - await exec.execute({ - sql: `INSERT INTO organization (id, name, slug, created_at, updated_at) VALUES ($1, $2, $3, NOW(), NOW())`, - args: [id, args.name.trim(), slug], - }); - } else { - await exec.execute({ - sql: `INSERT INTO organization (id, name, slug, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, - args: [id, args.name.trim(), slug, nowMs, nowMs], - }); - } - // Add the caller as an admin member — resolve user id if we can, else - // fall back to the email string (useful in dev where the user table may - // be bypassed by auth-skip). - const userId = (await resolveUserId(ownerEmail)) ?? ownerEmail; - const memberId = nanoid(); - if (pg) { - await exec.execute({ - sql: `INSERT INTO member (id, organization_id, user_id, role, created_at, updated_at) VALUES ($1, $2, $3, 'admin', NOW(), NOW())`, - args: [memberId, id, userId], - }); - } else { - await exec.execute({ - sql: `INSERT INTO member (id, organization_id, user_id, role, created_at, updated_at) VALUES (?, ?, ?, 'admin', ?, ?)`, - args: [memberId, id, userId, nowMs, nowMs], - }); - } + const { id, name } = await createOrganization( + args.name, + ownerEmail, + "admin", + ); - // Seed the Clips-specific sidecar. Our own organization_settings table - // was declared in schema.ts with TEXT created_at/updated_at so ISO - // strings are the right choice on both dialects. - if (pg) { + // Clips-specific sidecar — organization_settings uses TEXT timestamps. + const exec = getDbExec(); + const nowIso = new Date().toISOString(); + if (isPostgres()) { await exec.execute({ - sql: `INSERT INTO organization_settings (organization_id, brand_color, default_visibility, created_at, updated_at) VALUES ($1, '#18181B', 'private', $2, $3) ON CONFLICT (organization_id) DO NOTHING`, + sql: `INSERT INTO organization_settings (organization_id, brand_color, default_visibility, created_at, updated_at) VALUES (?, '#18181B', 'private', ?, ?) ON CONFLICT (organization_id) DO NOTHING`, args: [id, nowIso, nowIso], }); } else { @@ -125,12 +49,11 @@ export default defineAction({ await writeAppState("refresh-signal", { ts: Date.now() }); - console.log(`Created organization "${args.name}" (${id})`); + console.log(`Created organization "${name}" (${id})`); return { id, - name: args.name.trim(), - slug, + name, brandColor: "#18181B", brandLogoUrl: null, createdAt: nowIso, diff --git a/templates/clips/actions/create-recording.ts b/templates/clips/actions/create-recording.ts index f47ddaaa0..ab288e9f8 100644 --- a/templates/clips/actions/create-recording.ts +++ b/templates/clips/actions/create-recording.ts @@ -16,6 +16,7 @@ import { getCurrentOwnerEmail, nanoid, requireOrganizationAccess, + stringifySpaceIds, } from "../server/lib/recordings.js"; import { writeAppState } from "@agent-native/core/application-state"; import { @@ -56,6 +57,12 @@ export default defineAction({ .nullish() .describe("Captured window or browser tab title, when known"), folderId: z.string().nullish().describe("Optional folder ID"), + spaceIds: z + .array(z.string().min(1)) + .nullable() + .describe( + "Space IDs the recording should belong to (used when recording from a space)", + ), organizationId: z .string() .optional() @@ -97,11 +104,16 @@ export default defineAction({ args.organizationId, ); + const spaceIds = (args.spaceIds ?? []).filter( + (value, index, arr) => value && arr.indexOf(value) === index, + ); + await db.insert(schema.recordings).values({ id, organizationId, orgId: organizationId, folderId: args.folderId ?? null, + spaceIds: stringifySpaceIds(spaceIds), title, titleSource, sourceAppName: args.sourceAppName?.trim() || null, diff --git a/templates/clips/actions/create-space.ts b/templates/clips/actions/create-space.ts index a77a15582..d4b567ee5 100644 --- a/templates/clips/actions/create-space.ts +++ b/templates/clips/actions/create-space.ts @@ -23,11 +23,11 @@ export default defineAction({ color: z .string() .regex(/^#[0-9a-fA-F]{3,8}$/) - .optional() + .nullish() .describe("Hex color for the space chip"), iconEmoji: z .string() - .optional() + .nullish() .describe("Emoji glyph rendered next to the space name"), }), run: async (args) => { diff --git a/templates/clips/actions/decline-invite.ts b/templates/clips/actions/decline-invite.ts index e2077ed5f..a826b3c8b 100644 --- a/templates/clips/actions/decline-invite.ts +++ b/templates/clips/actions/decline-invite.ts @@ -1,7 +1,7 @@ /** * Decline an organization invite. * - * Marks the invitation as rejected (keeps the row around for audit). + * Marks the invitation as rejected (keeps the row for audit). * * Usage: * pnpm action decline-invite --token= @@ -9,7 +9,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { z } from "zod"; export default defineAction({ @@ -20,38 +20,27 @@ export default defineAction({ }), run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); const res = await exec.execute({ - sql: pg - ? `SELECT id, organization_id FROM invitation WHERE id = $1 LIMIT 1` - : `SELECT id, organization_id FROM invitation WHERE id = ? LIMIT 1`, + sql: `SELECT id, org_id FROM org_invitations WHERE id = ? LIMIT 1`, args: [args.token], }); const invite = ( res.rows as Array<{ id?: string; - organization_id?: string; + org_id?: string; }> )[0]; if (!invite?.id) { return { declined: false, error: "Invite not found." }; } - const nowMs = Date.now(); - if (pg) { - await exec.execute({ - sql: `UPDATE invitation SET status = 'rejected', updated_at = NOW() WHERE id = $1`, - args: [invite.id], - }); - } else { - await exec.execute({ - sql: `UPDATE invitation SET status = 'rejected', updated_at = ? WHERE id = ?`, - args: [nowMs, invite.id], - }); - } + await exec.execute({ + sql: `UPDATE org_invitations SET status = 'rejected' WHERE id = ?`, + args: [invite.id], + }); await writeAppState("refresh-signal", { ts: Date.now() }); - return { declined: true, organizationId: invite.organization_id }; + return { declined: true, organizationId: invite.org_id }; }, }); diff --git a/templates/clips/actions/get-invite.ts b/templates/clips/actions/get-invite.ts index 61feeb2d5..0791e2c36 100644 --- a/templates/clips/actions/get-invite.ts +++ b/templates/clips/actions/get-invite.ts @@ -1,35 +1,31 @@ /** * Look up an invite by its token. * - * With better-auth's invitation table, the invitation id IS the token — - * accept URLs point at `/invite/`. + * The invitation id IS the token — accept URLs point at `/invite/`. * * Usage: * pnpm action get-invite --token= */ import { defineAction } from "@agent-native/core"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { z } from "zod"; interface InviteRow { id: string; - organization_id: string; + org_id: string; email: string | null; role: string | null; status: string | null; - expires_at: string | number | null; - inviter_id: string | null; - created_at: string | number | null; + invited_by: string | null; + created_at: number | string | null; org_name?: string | null; brand_color?: string | null; - inviter_email?: string | null; } -function toIsoIfMs(v: string | number | null): string | null { +function toIsoIfMs(v: number | string | null): string | null { if (v === null || v === undefined) return null; if (typeof v === "number") return new Date(v).toISOString(); - // SQLite sometimes returns numeric strings for INTEGER columns. const parsed = Number(v); if (!Number.isNaN(parsed) && /^\d+$/.test(String(v))) { return new Date(parsed).toISOString(); @@ -46,28 +42,14 @@ export default defineAction({ http: { method: "GET" }, run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); - const userTable = pg ? `"user"` : `user`; const res = await exec.execute({ - sql: pg - ? `SELECT i.id, i.organization_id, i.email, i.role, i.status, i.expires_at, i.inviter_id, i.created_at, + sql: `SELECT i.id, i.org_id, i.email, i.role, i.status, i.invited_by, i.created_at, o.name AS org_name, - s.brand_color AS brand_color, - u.email AS inviter_email - FROM invitation i - LEFT JOIN organization o ON o.id = i.organization_id - LEFT JOIN organization_settings s ON s.organization_id = i.organization_id - LEFT JOIN ${userTable} u ON u.id = i.inviter_id - WHERE i.id = $1 LIMIT 1` - : `SELECT i.id, i.organization_id, i.email, i.role, i.status, i.expires_at, i.inviter_id, i.created_at, - o.name AS org_name, - s.brand_color AS brand_color, - u.email AS inviter_email - FROM invitation i - LEFT JOIN organization o ON o.id = i.organization_id - LEFT JOIN organization_settings s ON s.organization_id = i.organization_id - LEFT JOIN ${userTable} u ON u.id = i.inviter_id + s.brand_color AS brand_color + FROM org_invitations i + LEFT JOIN organizations o ON o.id = i.org_id + LEFT JOIN organization_settings s ON s.organization_id = i.org_id WHERE i.id = ? LIMIT 1`, args: [args.token], }); @@ -85,11 +67,6 @@ export default defineAction({ return { invite: null, error: "This invite is no longer valid." }; } - const expiresAt = toIsoIfMs(row.expires_at); - if (expiresAt && new Date(expiresAt).getTime() < Date.now()) { - return { invite: null, error: "This invite has expired." }; - } - if (!row.org_name) { return { invite: null, error: "Organization no longer exists." }; } @@ -97,13 +74,12 @@ export default defineAction({ return { invite: { id: row.id, - organizationId: row.organization_id, + organizationId: row.org_id, organizationName: row.org_name, brandColor: row.brand_color ?? "#18181B", email: row.email ?? "", role: row.role ?? "member", - invitedBy: row.inviter_email ?? row.inviter_id ?? "", - expiresAt, + invitedBy: row.invited_by ?? "", acceptedAt: status === "accepted" ? toIsoIfMs(row.created_at) : null, status, }, diff --git a/templates/clips/actions/invite-member.ts b/templates/clips/actions/invite-member.ts index 4b2772542..aa1900107 100644 --- a/templates/clips/actions/invite-member.ts +++ b/templates/clips/actions/invite-member.ts @@ -1,11 +1,10 @@ /** * Invite an email address to the active organization. * - * Creates an `invitation` row in the better-auth invitation table with a - * 30-day expiry. Clips role mapping: `admin` → `admin`, everything else → - * `member`. Returns the invitation id/token. Sends an email via the - * framework email helper when a provider is configured — otherwise logs - * the accept URL to the console. + * Creates an `org_invitations` row with status `pending`. Clips role mapping: + * `admin` → `admin`, everything else → `member`. Returns the invitation id + * (which is the accept token). Sends an email via the framework email helper + * when a provider is configured. * * Usage: * pnpm action invite-member --email=alice@example.com --role=admin @@ -13,7 +12,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { emit } from "@agent-native/core/event-bus"; import { sendEmail, @@ -32,9 +31,9 @@ function getAppName(): string { return process.env.APP_NAME || "Clips"; } -// Accept the current better-auth role surface plus the old Clips roles for +// Accept the current admin/member surface plus legacy Clips roles for // backwards-compatible CLI/agent calls. Legacy non-admin roles collapse to -// `member` when writing to better-auth. +// `member`. const ClipsRoleEnum = z.enum([ "viewer", "creator-lite", @@ -47,8 +46,6 @@ function mapRole(role: z.infer): "admin" | "member" { return role === "admin" ? "admin" : "member"; } -const DAY_MS = 24 * 60 * 60 * 1000; - function baseUrl(): string { return ( process.env.APP_URL || @@ -58,28 +55,11 @@ function baseUrl(): string { ).replace(/\/+$/, ""); } -async function resolveUserId(email: string): Promise { +async function fetchOrgName(orgId: string): Promise { const exec = getDbExec(); - try { - const sql = isPostgres() - ? `SELECT id FROM "user" WHERE email = $1 LIMIT 1` - : `SELECT id FROM user WHERE email = ? LIMIT 1`; - const res = await exec.execute({ sql, args: [email] }); - const row = (res.rows as Array<{ id?: string }>)[0]; - return row?.id ?? null; - } catch { - return null; - } -} - -async function fetchOrgName(organizationId: string): Promise { - const exec = getDbExec(); - const pg = isPostgres(); const res = await exec.execute({ - sql: pg - ? `SELECT name FROM organization WHERE id = $1 LIMIT 1` - : `SELECT name FROM organization WHERE id = ? LIMIT 1`, - args: [organizationId], + sql: `SELECT name FROM organizations WHERE id = ? LIMIT 1`, + args: [orgId], }); const row = (res.rows as Array<{ name?: string }>)[0]; return row?.name ?? "Organization"; @@ -87,7 +67,7 @@ async function fetchOrgName(organizationId: string): Promise { export default defineAction({ description: - "Invite someone to the active organization by email. Creates a pending invitation with a 30-day expiry. Role 'admin' maps to better-auth admin; all other Clips roles collapse to 'member'. Sends an email when a provider is configured.", + "Invite someone to the active organization by email. Creates a pending invitation. Role 'admin' maps to admin; all other Clips roles collapse to 'member'. Sends an email when a provider is configured.", schema: z.object({ email: z.string().email().describe("Invitee email address"), role: ClipsRoleEnum.default("member").describe( @@ -96,86 +76,49 @@ export default defineAction({ }), run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); const { organizationId } = await requireOrganizationAccess(undefined, [ "admin", ]); const inviter = getCurrentOwnerEmail(); - const inviterUserId = (await resolveUserId(inviter)) ?? inviter; - const betterAuthRole = mapRole(args.role); + const role = mapRole(args.role); + const inviteeEmail = args.email.trim().toLowerCase(); - // If there's already a pending invite for this email, rotate its id/expiry. + // Rotate any existing pending invite for this email so the latest one is + // the only live token. const existingRes = await exec.execute({ - sql: pg - ? `SELECT id FROM invitation WHERE organization_id = $1 AND email = $2 AND status = 'pending' LIMIT 1` - : `SELECT id FROM invitation WHERE organization_id = ? AND email = ? AND status = 'pending' LIMIT 1`, - args: [organizationId, args.email], + sql: `SELECT id FROM org_invitations WHERE org_id = ? AND LOWER(email) = ? AND status = 'pending' LIMIT 1`, + args: [organizationId, inviteeEmail], }); const existing = (existingRes.rows as Array<{ id?: string }>)[0]; - - // better-auth's invitation table has no separate token column — the - // invitation id IS the token (accept routes look up by id). - const id = nanoid(24); - const token = id; - const nowMs = Date.now(); - const nowIso = new Date(nowMs).toISOString(); - const expiresMs = nowMs + 30 * DAY_MS; - const expiresIso = new Date(expiresMs).toISOString(); - if (existing?.id) { - // Cancel the old pending row. await exec.execute({ - sql: pg - ? `UPDATE invitation SET status = 'canceled', updated_at = NOW() WHERE id = $1` - : `UPDATE invitation SET status = 'canceled', updated_at = ? WHERE id = ?`, - args: pg ? [existing.id] : [nowMs, existing.id], + sql: `UPDATE org_invitations SET status = 'canceled' WHERE id = ?`, + args: [existing.id], }); } - if (pg) { - await exec.execute({ - sql: `INSERT INTO invitation (id, organization_id, email, role, status, expires_at, inviter_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, 'pending', $5, $6, NOW(), NOW())`, - args: [ - id, - organizationId, - args.email, - betterAuthRole, - expiresIso, - inviterUserId, - ], - }); - } else { - await exec.execute({ - sql: `INSERT INTO invitation (id, organization_id, email, role, status, expires_at, inviter_id, created_at, updated_at) - VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?)`, - args: [ - id, - organizationId, - args.email, - betterAuthRole, - expiresMs, - inviterUserId, - nowMs, - nowMs, - ], - }); - } + const id = nanoid(24); + const token = id; + const nowMs = Date.now(); + + await exec.execute({ + sql: `INSERT INTO org_invitations (id, org_id, email, invited_by, created_at, status, role) VALUES (?, ?, ?, ?, ?, 'pending', ?)`, + args: [id, organizationId, args.email, inviter, nowMs, role], + }); const orgName = await fetchOrgName(organizationId); const inviteUrl = `${baseUrl()}/invite/${token}`; - const appName = getAppName() ?? "Clips"; + const appName = getAppName(); const { html, text } = renderEmail({ preheader: `${inviter} invited you to ${orgName} on ${appName}.`, heading: `You're invited to join ${orgName}`, paragraphs: [ - `${emailStrong(inviter)} invited you to the ${emailStrong(orgName)} organization on ${emailStrong(appName)} as ${emailStrong(betterAuthRole)}.`, + `${emailStrong(inviter)} invited you to the ${emailStrong(orgName)} organization on ${emailStrong(appName)} as ${emailStrong(role)}.`, `Click the button below to accept the invite and start collaborating.`, ], cta: { label: "Accept invite", url: inviteUrl }, - footer: "This invite expires in 30 days.", brandColor: "#18181B", }); try { @@ -191,14 +134,10 @@ export default defineAction({ await writeAppState("refresh-signal", { ts: Date.now() }); - // Emit clip.shared event — best-effort, never block the main flow. try { emit( "clip.shared", - { - sharedWith: args.email, - sharedBy: inviter, - }, + { sharedWith: args.email, sharedBy: inviter }, { owner: inviter }, ); } catch (err) { @@ -211,10 +150,9 @@ export default defineAction({ id, organizationId, email: args.email, - role: betterAuthRole, + role, status: "pending" as const, token, - expiresAt: expiresIso, inviteUrl, emailConfigured: isEmailConfigured(), }; diff --git a/templates/clips/actions/list-organization-state.ts b/templates/clips/actions/list-organization-state.ts index b9d7198a6..e6c767ac9 100644 --- a/templates/clips/actions/list-organization-state.ts +++ b/templates/clips/actions/list-organization-state.ts @@ -12,7 +12,7 @@ import { defineAction } from "@agent-native/core"; import { and, asc, eq, isNotNull, or } from "drizzle-orm"; import { z } from "zod"; import { getDb, schema } from "../server/db/index.js"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { getCurrentOwnerEmail, requireOrganizationAccess, @@ -21,10 +21,7 @@ import { interface OrgRow { id: string; name: string; - slug: string | null; - logo?: string | null; - created_at?: string | null; - updated_at?: string | null; + created_at: number | string | null; } interface SettingsRow { @@ -39,7 +36,7 @@ interface MemberRow { id: string; email: string | null; role: string | null; - created_at?: string | null; + joined_at: number | string | null; } interface InvitationRow { @@ -47,8 +44,7 @@ interface InvitationRow { email: string | null; role: string | null; status: string | null; - expires_at: string | null; - created_at: string | null; + created_at: number | string | null; } export default defineAction({ @@ -59,25 +55,21 @@ export default defineAction({ .string() .optional() .describe( - "Override the active organization. If omitted, resolves from the session's active_organization_id / member lookup.", + "Override the active organization. If omitted, resolves from the caller's active-org-id user-setting / org_members lookup.", ), }), http: { method: "GET" }, run: async (args) => { const db = getDb(); const exec = getDbExec(); - const pg = isPostgres(); const ownerEmail = getCurrentOwnerEmail(); const { organizationId } = await requireOrganizationAccess( args.organizationId, ); - // Organization row const orgRes = await exec.execute({ - sql: pg - ? `SELECT id, name, slug, logo, created_at, updated_at FROM organization WHERE id = $1 LIMIT 1` - : `SELECT id, name, slug, logo, created_at, updated_at FROM organization WHERE id = ? LIMIT 1`, + sql: `SELECT id, name, created_at FROM organizations WHERE id = ? LIMIT 1`, args: [organizationId], }); const org = (orgRes.rows as OrgRow[])[0]; @@ -92,43 +84,25 @@ export default defineAction({ }; } - // Settings sidecar const settingsRes = await exec.execute({ - sql: pg - ? `SELECT brand_color, brand_logo_url, default_visibility, created_at, updated_at FROM organization_settings WHERE organization_id = $1 LIMIT 1` - : `SELECT brand_color, brand_logo_url, default_visibility, created_at, updated_at FROM organization_settings WHERE organization_id = ? LIMIT 1`, + sql: `SELECT brand_color, brand_logo_url, default_visibility, created_at, updated_at FROM organization_settings WHERE organization_id = ? LIMIT 1`, args: [organizationId], }); const settings = (settingsRes.rows as SettingsRow[])[0] ?? null; - // Members + emails — join to the user table. - const userTable = pg ? `"user"` : `user`; const memberRes = await exec.execute({ - sql: pg - ? `SELECT m.id AS id, u.email AS email, m.role AS role, m.created_at AS created_at - FROM member m - LEFT JOIN ${userTable} u ON u.id = m.user_id - WHERE m.organization_id = $1 - ORDER BY m.created_at ASC` - : `SELECT m.id AS id, u.email AS email, m.role AS role, m.created_at AS created_at - FROM member m - LEFT JOIN ${userTable} u ON u.id = m.user_id - WHERE m.organization_id = ? - ORDER BY m.created_at ASC`, + sql: `SELECT id, email, role, joined_at FROM org_members WHERE org_id = ? ORDER BY joined_at ASC`, args: [organizationId], }); const members = (memberRes.rows as MemberRow[]).map((m) => ({ id: String(m.id), email: m.email ?? "", role: m.role ?? "member", - joinedAt: m.created_at ?? null, + joinedAt: m.joined_at !== null ? Number(m.joined_at) : null, })); - // Pending invitations const inviteRes = await exec.execute({ - sql: pg - ? `SELECT id, email, role, status, expires_at, created_at FROM invitation WHERE organization_id = $1 AND status = 'pending' ORDER BY created_at DESC` - : `SELECT id, email, role, status, expires_at, created_at FROM invitation WHERE organization_id = ? AND status = 'pending' ORDER BY created_at DESC`, + sql: `SELECT id, email, role, status, created_at FROM org_invitations WHERE org_id = ? AND status = 'pending' ORDER BY created_at DESC`, args: [organizationId], }); const invitations = (inviteRes.rows as InvitationRow[]).map((i) => ({ @@ -136,11 +110,9 @@ export default defineAction({ email: i.email ?? "", role: i.role ?? "member", status: i.status ?? "pending", - expiresAt: i.expires_at ?? null, - createdAt: i.created_at ?? null, + createdAt: i.created_at !== null ? Number(i.created_at) : null, })); - // Spaces + folders via Drizzle const [spaces, folders] = await Promise.all([ db .select() @@ -167,12 +139,10 @@ export default defineAction({ organization: { id: org.id, name: org.name, - slug: org.slug ?? null, brandColor: settings?.brand_color ?? "#18181B", - brandLogoUrl: settings?.brand_logo_url ?? org.logo ?? null, + brandLogoUrl: settings?.brand_logo_url ?? null, defaultVisibility: settings?.default_visibility ?? "private", - createdAt: org.created_at ?? null, - updatedAt: org.updated_at ?? null, + createdAt: org.created_at !== null ? Number(org.created_at) : null, }, members, spaces: spaces.map((s) => ({ diff --git a/templates/clips/actions/list-recordings.ts b/templates/clips/actions/list-recordings.ts index 23b01dc41..600d50740 100644 --- a/templates/clips/actions/list-recordings.ts +++ b/templates/clips/actions/list-recordings.ts @@ -61,19 +61,19 @@ export default defineAction({ accessFilter(schema.recordings, schema.recordingShares), ]; - // `accessFilter` already scopes org-visible rows to the active org and - // never includes public rows in list views. Keep owner rows visible across - // org switches so joining a new organization does not hide older personal - // recordings. const orgId = await getActiveOrganizationId(); - // Library = "Your personal recordings" — further scope to the current - // user's own clips so org-visible recordings from teammates don't appear. + // Library = "Your personal recordings in the active org". `accessFilter` + // admits all owner rows regardless of org, so library must add both the + // owner-email and current-org predicates to scope correctly. if (args.view === "library") { const email = getRequestUserEmail(); if (email) { whereClauses.push(eq(schema.recordings.ownerEmail, email)); } + if (orgId) { + whereClauses.push(eq(schema.recordings.organizationId, orgId)); + } } // Lifecycle view filters diff --git a/templates/clips/actions/remove-member.ts b/templates/clips/actions/remove-member.ts index 31ac6e93b..6a80c3d6a 100644 --- a/templates/clips/actions/remove-member.ts +++ b/templates/clips/actions/remove-member.ts @@ -1,7 +1,7 @@ /** * Remove a member from the active organization. * - * Admin-only. Rejects if the target is the last admin. + * Admin-only. Refuses to remove the organization owner. * * Usage: * pnpm action remove-member --email=alice@example.com @@ -9,13 +9,13 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { z } from "zod"; import { requireOrganizationAccess } from "../server/lib/recordings.js"; export default defineAction({ description: - "Remove a member from the active organization. Admin-only. Rejects removing the last admin.", + "Remove a member from the active organization. Admin-only. Refuses to remove the owner.", schema: z.object({ organizationId: z .string() @@ -25,50 +25,28 @@ export default defineAction({ }), run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); const { organizationId } = await requireOrganizationAccess( args.organizationId, ["admin"], ); + const targetEmailLower = args.email.toLowerCase(); - // Check the target exists and get their role. const targetRes = await exec.execute({ - sql: pg - ? `SELECT m.id, m.role FROM member m JOIN "user" u ON u.id = m.user_id WHERE m.organization_id = $1 AND u.email = $2 LIMIT 1` - : `SELECT m.id, m.role FROM member m JOIN user u ON u.id = m.user_id WHERE m.organization_id = ? AND u.email = ? LIMIT 1`, - args: [organizationId, args.email], + sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, + args: [organizationId, targetEmailLower], }); - const target = (targetRes.rows as Array<{ id?: string; role?: string }>)[0]; + const target = (targetRes.rows as Array<{ role?: string }>)[0]; if (!target) { throw new Error(`Member not found: ${args.email}`); } - - if (target.role === "admin") { - const adminsRes = await exec.execute({ - sql: pg - ? `SELECT COUNT(*) AS count FROM member WHERE organization_id = $1 AND role = 'admin'` - : `SELECT COUNT(*) AS count FROM member WHERE organization_id = ? AND role = 'admin'`, - args: [organizationId], - }); - const adminCount = Number((adminsRes.rows as any[])[0]?.count ?? 0); - if (adminCount <= 1) { - throw new Error( - "Cannot remove the last admin. Promote another member to admin first.", - ); - } + if (target.role === "owner") { + throw new Error("Cannot remove the organization owner."); } - if (pg) { - await exec.execute({ - sql: `DELETE FROM member WHERE organization_id = $1 AND user_id = (SELECT id FROM "user" WHERE email = $2)`, - args: [organizationId, args.email], - }); - } else { - await exec.execute({ - sql: `DELETE FROM member WHERE organization_id = ? AND user_id = (SELECT id FROM user WHERE email = ?)`, - args: [organizationId, args.email], - }); - } + await exec.execute({ + sql: `DELETE FROM org_members WHERE org_id = ? AND LOWER(email) = ?`, + args: [organizationId, targetEmailLower], + }); await writeAppState("refresh-signal", { ts: Date.now() }); diff --git a/templates/clips/actions/set-organization-branding.ts b/templates/clips/actions/set-organization-branding.ts index d8e2816f9..dc2ec77a3 100644 --- a/templates/clips/actions/set-organization-branding.ts +++ b/templates/clips/actions/set-organization-branding.ts @@ -1,7 +1,7 @@ /** * Update organization branding — brand color, brand logo URL, default * visibility — by upserting the Clips-specific `organization_settings` - * sidecar row. Does NOT change the core better-auth `organization` row. + * sidecar row. Does NOT change the framework `organizations` row. * * Usage: * pnpm action set-organization-branding --brandColor="#18181B" --brandLogoUrl=/api/media/abc.png diff --git a/templates/clips/actions/update-member-role.ts b/templates/clips/actions/update-member-role.ts index bec9ac33a..05b0f54de 100644 --- a/templates/clips/actions/update-member-role.ts +++ b/templates/clips/actions/update-member-role.ts @@ -1,8 +1,9 @@ /** * Update an organization member's role. * - * Admin-only. Clips role mapping collapses to better-auth's two-tier model: + * Admin-only. Clips role mapping collapses to two invitable roles: * admin → admin, anything else → member. + * Refuses to change the owner's role. * * Usage: * pnpm action update-member-role --email=alice@example.com --role=admin @@ -10,7 +11,7 @@ import { defineAction } from "@agent-native/core"; import { writeAppState } from "@agent-native/core/application-state"; -import { getDbExec, isPostgres } from "@agent-native/core/db"; +import { getDbExec } from "@agent-native/core/db"; import { z } from "zod"; import { requireOrganizationAccess } from "../server/lib/recordings.js"; @@ -28,7 +29,7 @@ function mapRole(role: z.infer): "admin" | "member" { export default defineAction({ description: - "Change an organization member's role. Admin-only. Clips role 'admin' maps to better-auth admin; all other roles collapse to 'member'.", + "Change an organization member's role. Admin-only. Clips role 'admin' maps to admin; all other roles collapse to 'member'. Cannot change the owner's role.", schema: z.object({ organizationId: z .string() @@ -39,73 +40,40 @@ export default defineAction({ }), run: async (args) => { const exec = getDbExec(); - const pg = isPostgres(); const { organizationId } = await requireOrganizationAccess( args.organizationId, ["admin"], ); - const betterAuthRole = mapRole(args.role); + const role = mapRole(args.role); + const targetEmailLower = args.email.toLowerCase(); - // Verify the target member exists. const existsRes = await exec.execute({ - sql: pg - ? `SELECT m.id FROM member m JOIN "user" u ON u.id = m.user_id WHERE m.organization_id = $1 AND u.email = $2 LIMIT 1` - : `SELECT m.id FROM member m JOIN user u ON u.id = m.user_id WHERE m.organization_id = ? AND u.email = ? LIMIT 1`, - args: [organizationId, args.email], + sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, + args: [organizationId, targetEmailLower], }); - if (!(existsRes.rows as any[]).length) { + const existing = (existsRes.rows as Array<{ role?: string }>)[0]; + if (!existing) { throw new Error(`Member not found: ${args.email}`); } - - // Last-admin guard — refuse to demote the only admin. - if (betterAuthRole !== "admin") { - const adminsRes = await exec.execute({ - sql: pg - ? `SELECT COUNT(*) AS count FROM member WHERE organization_id = $1 AND role = 'admin'` - : `SELECT COUNT(*) AS count FROM member WHERE organization_id = ? AND role = 'admin'`, - args: [organizationId], - }); - const adminCount = Number((adminsRes.rows as any[])[0]?.count ?? 0); - // Check whether THIS user is currently admin — if yes and they're the - // only admin, refuse. - const targetRoleRes = await exec.execute({ - sql: pg - ? `SELECT m.role FROM member m JOIN "user" u ON u.id = m.user_id WHERE m.organization_id = $1 AND u.email = $2 LIMIT 1` - : `SELECT m.role FROM member m JOIN user u ON u.id = m.user_id WHERE m.organization_id = ? AND u.email = ? LIMIT 1`, - args: [organizationId, args.email], - }); - const currentRole = (targetRoleRes.rows as Array<{ role?: string }>)[0] - ?.role; - if (currentRole === "admin" && adminCount <= 1) { - throw new Error( - "Cannot demote the last admin. Promote another member to admin first.", - ); - } + if (existing.role === "owner") { + throw new Error("Cannot change the organization owner's role."); } - const nowMs = Date.now(); - if (pg) { - await exec.execute({ - sql: `UPDATE member SET role = $1, updated_at = NOW() WHERE organization_id = $2 AND user_id = (SELECT id FROM "user" WHERE email = $3)`, - args: [betterAuthRole, organizationId, args.email], - }); - } else { - await exec.execute({ - sql: `UPDATE member SET role = ?, updated_at = ? WHERE organization_id = ? AND user_id = (SELECT id FROM user WHERE email = ?)`, - args: [betterAuthRole, nowMs, organizationId, args.email], - }); - } + await exec.execute({ + sql: `UPDATE org_members SET role = ? WHERE org_id = ? AND LOWER(email) = ?`, + args: [role, organizationId, targetEmailLower], + }); await writeAppState("refresh-signal", { ts: Date.now() }); console.log( - `Updated role for ${args.email} in organization ${organizationId} to ${betterAuthRole}`, + `Updated role for ${args.email} in organization ${organizationId} to ${role}`, ); return { organizationId, email: args.email, - role: betterAuthRole, + role, }; }, }); diff --git a/templates/clips/app/components/library/empty-state.tsx b/templates/clips/app/components/library/empty-state.tsx index 8f4d5a3f8..3c1c310ad 100644 --- a/templates/clips/app/components/library/empty-state.tsx +++ b/templates/clips/app/components/library/empty-state.tsx @@ -58,10 +58,11 @@ const COPY: Record = { interface EmptyStateProps { kind: EmptyKind; + spaceId?: string | null; onCtaClick?: () => void; } -export function EmptyState({ kind, onCtaClick }: EmptyStateProps) { +export function EmptyState({ kind, spaceId, onCtaClick }: EmptyStateProps) { const navigate = useNavigate(); const Icon = ICONS[kind]; const copy = COPY[kind]; @@ -70,7 +71,9 @@ export function EmptyState({ kind, onCtaClick }: EmptyStateProps) { if (onCtaClick) { onCtaClick(); } else { - navigate("/record"); + navigate( + spaceId ? `/record?spaceId=${encodeURIComponent(spaceId)}` : "/record", + ); } }; diff --git a/templates/clips/app/components/library/library-grid.tsx b/templates/clips/app/components/library/library-grid.tsx index 481f0ed72..a511791b1 100644 --- a/templates/clips/app/components/library/library-grid.tsx +++ b/templates/clips/app/components/library/library-grid.tsx @@ -257,7 +257,7 @@ export function LibraryGrid({ ))} ) : recordings.length === 0 ? ( - + ) : (
{recordings.map((r: RecordingSummary) => ( diff --git a/templates/clips/app/routes/_app.settings.organization.tsx b/templates/clips/app/routes/_app.settings.organization.tsx index baf883dc3..c4d8b5796 100644 --- a/templates/clips/app/routes/_app.settings.organization.tsx +++ b/templates/clips/app/routes/_app.settings.organization.tsx @@ -28,7 +28,6 @@ interface OrganizationStateResponse { organization: { id: string; name: string; - slug: string; brandColor: string; brandLogoUrl: string | null; defaultVisibility: string; @@ -46,7 +45,6 @@ interface OrganizationStateResponse { email: string; role: MemberRole; createdAt: string; - expiresAt: string | null; }[]; } @@ -191,7 +189,6 @@ export default function OrganizationSettingsRoute() { Email Role Sent - Expires @@ -208,11 +205,6 @@ export default function OrganizationSettingsRoute() { {new Date(inv.createdAt).toLocaleDateString()} - - {inv.expiresAt - ? new Date(inv.expiresAt).toLocaleDateString() - : "—"} - ))} diff --git a/templates/clips/app/routes/record.tsx b/templates/clips/app/routes/record.tsx index 3bde256c3..73a62c233 100644 --- a/templates/clips/app/routes/record.tsx +++ b/templates/clips/app/routes/record.tsx @@ -632,6 +632,13 @@ export default function RecordRoute() { const queryClient = useQueryClient(); const { isDesktopApp } = useDesktopPromo(); const storageQuery = useVideoStorageStatus(); + + // When the user clicks "Record for this space", the empty-state CTA appends + // ?spaceId=... so the new recording lands in that space. + const spaceIdFromUrl = useMemo(() => { + const params = new URLSearchParams(location.search); + return params.get("spaceId") || null; + }, [location.search]); const storageConfigured: boolean | null = storageQuery.isLoading ? null : !!storageQuery.data?.configured; @@ -889,6 +896,7 @@ export default function RecordRoute() { hasCamera: opts.mode !== "screen", hasAudio: wantsMic, visibility: "public", + spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined, }), }, ); @@ -1155,6 +1163,7 @@ export default function RecordRoute() { hasAudio: true, width: meta.width, height: meta.height, + spaceIds: spaceIdFromUrl ? [spaceIdFromUrl] : undefined, }), }, ); diff --git a/templates/clips/server/db/schema.ts b/templates/clips/server/db/schema.ts index 9b45b27ea..28ecf2ce0 100644 --- a/templates/clips/server/db/schema.ts +++ b/templates/clips/server/db/schema.ts @@ -9,21 +9,23 @@ import { } from "@agent-native/core/db/schema"; // ----------------------------------------------------------------------------- -// Organizations (new canonical "team" primitive, powered by better-auth). +// Organizations. // -// Team / member / invitation rows live in better-auth's own tables: -// `organization`, `member`, `invitation` — managed by the framework. +// Team / member / invitation rows live in the framework's own tables: +// `organizations`, `org_members`, `org_invitations` — owned by +// `packages/core/src/org/`. // // `organization_settings` is the Clips-specific sidecar: brand color, logo, -// default visibility — one row per organization, keyed by `organization.id`. +// default visibility — one row per organization, keyed by `organizations.id`. // // ----------------------------------------------------------------------------- // Workspaces & members (DEPRECATED — kept only for the in-place migration -// from the old Clips workspace model to better-auth orgs. Every new Clips -// deploy auto-backfills an `organization` + `organization_settings` row for -// every workspace row at startup (see `server/plugins/db.ts`), keeping the -// same id across both. Actions and UI will migrate off these tables in a -// follow-up; at that point these table definitions can be deleted.) +// from the old Clips workspace model to the framework org primitive. Every +// Clips deploy auto-backfills an `organizations` + `organization_settings` +// row plus `org_members` entries for every workspace row at startup (see +// `server/plugins/db.ts`), keeping the same id across both. Actions and UI +// have migrated off these tables; the table definitions remain only so the +// startup backfill has a source to read from on existing deployments.) // ----------------------------------------------------------------------------- export const organizationSettings = table("organization_settings", { diff --git a/templates/clips/server/lib/recordings.ts b/templates/clips/server/lib/recordings.ts index 6261fae13..746e65fa3 100644 --- a/templates/clips/server/lib/recordings.ts +++ b/templates/clips/server/lib/recordings.ts @@ -7,7 +7,6 @@ import { getRequestOrgId, } from "@agent-native/core/server/request-context"; import { readAppState } from "@agent-native/core/application-state"; -import { isPostgres } from "@agent-native/core/db"; export function getCurrentOwnerEmail(): string { const email = getRequestUserEmail(); @@ -64,53 +63,25 @@ function organizationRoleAllowed( return ORG_ROLE_RANK[actual] >= required; } -function highestOrganizationRole( - roles: Array, -): OrganizationAccessRole | null { - return roles.reduce((best, role) => { - if (!role) return best; - if (!best || ORG_ROLE_RANK[role] > ORG_ROLE_RANK[best]) return role; - return best; - }, null); -} - export async function getOrganizationRoleForEmail( organizationId: string, email: string, ): Promise { const exec = getDbExec(); - const pg = isPostgres(); const lowerEmail = email.toLowerCase(); - const roles: Array = []; - - try { - const res = await exec.execute({ - sql: pg - ? `SELECT role FROM org_members WHERE org_id = $1 AND LOWER(email) = $2 LIMIT 1` - : `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, - args: [organizationId, lowerEmail], - }); - const row = (res.rows as Array<{ role?: string }>)[0]; - if (row?.role) roles.push(normalizeOrganizationRole(row.role)); - } catch { - // Older DBs may not have the framework org_members table yet. Fall back - // to better-auth's member table below. - } try { const res = await exec.execute({ - sql: pg - ? `SELECT m.role FROM member m JOIN "user" u ON u.id = m.user_id WHERE m.organization_id = $1 AND LOWER(u.email) = $2 LIMIT 1` - : `SELECT m.role FROM member m JOIN user u ON u.id = m.user_id WHERE m.organization_id = ? AND LOWER(u.email) = ? LIMIT 1`, + sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`, args: [organizationId, lowerEmail], }); const row = (res.rows as Array<{ role?: string }>)[0]; - if (row?.role) roles.push(normalizeOrganizationRole(row.role)); + if (row?.role) return normalizeOrganizationRole(row.role); } catch { - // No better-auth membership table yet. + // org_members table may not exist yet on first boot before migrations finish. } - return highestOrganizationRole(roles); + return null; } export async function requireOrganizationAccess( @@ -169,28 +140,18 @@ export async function getActiveOrganizationId( if (email) { try { - const ph = isPostgres() ? "$1" : "?"; - const res = await exec.execute({ - sql: `SELECT org_id AS id FROM org_members WHERE LOWER(email) = ${ph} ORDER BY joined_at DESC LIMIT 1`, - args: [email.toLowerCase()], - }); - const row = (res.rows as Array<{ id?: string }>)[0]; - if (row?.id) return row.id; + // Honors the user's `active-org-id` setting with a fall back to the + // first membership — the same logic getOrgContext uses for HTTP paths. + // Don't reach into org_members directly: an ORDER BY here picks the + // wrong org when the user belongs to more than one. + const { resolveOrgIdForEmail } = await import("@agent-native/core/org"); + const orgId = await resolveOrgIdForEmail(email); + if (orgId) return orgId; } catch { // fall through } } - try { - const res = await exec.execute( - `SELECT id FROM organizations ORDER BY created_at DESC LIMIT 1`, - ); - const row = (res.rows as Array<{ id?: string }>)[0]; - if (row?.id) return row.id; - } catch { - // fall through - } - // Legacy fallback: old workspace UI's `current-workspace` app-state key, // and the deprecated `workspaces` table. try { diff --git a/templates/clips/server/plugins/db.ts b/templates/clips/server/plugins/db.ts index 9fec09ed7..e23c2f2a4 100644 --- a/templates/clips/server/plugins/db.ts +++ b/templates/clips/server/plugins/db.ts @@ -305,11 +305,12 @@ const migrations = runMigrations( )`, }, // --------------------------------------------------------------------------- - // Organization settings — Clips-specific sidecar to better-auth `organization` + // Organization settings — Clips-specific sidecar to the framework + // `organizations` table. // // One row per organization. Brand color + logo + default visibility live - // here; membership and invitations live in better-auth's tables. This - // replaces `workspaces.brand_color` / `.brand_logo_url` / `.default_visibility` + // here; membership and invitations live in `org_members` / `org_invitations`. + // This replaces `workspaces.brand_color` / `.brand_logo_url` / `.default_visibility` // once callsites migrate. // --------------------------------------------------------------------------- { @@ -644,12 +645,10 @@ const migrations = runMigrations( * seeded — an admin `org_members` row. Invites are copied into * `org_invitations`. * - * The framework ships two parallel org systems — the simpler email-based - * one (`organizations` / `org_members` / `org_invitations`, which `/_agent-native/org/*` - * endpoints + `useOrg` read) and better-auth's own `organization` / `member` / - * `invitation` tables. Clips rides on the simpler system because the - * framework client hooks and the `share-resource` action both resolve - * membership there. + * Clips uses the framework's email-based org system (`organizations` / + * `org_members` / `org_invitations`), which the `/_agent-native/org/*` + * endpoints + `useOrg` client hook + `share-resource` action all resolve + * membership through. * * Runs on every startup after the schema migrations. Safe to re-run: all * inserts are guarded with WHERE-NOT-EXISTS so it only writes rows that @@ -746,7 +745,7 @@ async function syncWorkspacesToOrganizations(): Promise { ); } - // 3a) Seed each workspace owner as an admin `org_members` row. Owners + // 3a) Seed each workspace owner as an owner `org_members` row. Owners // were implicitly members in the old Clips workspace model — this is // the step that lands the current user inside their new org. try {