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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/agent_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@
NOTION_SHARED_CLIENT_SECRET,
HUBSPOT_SHARED_CLIENT_ID,
HUBSPOT_SHARED_CLIENT_SECRET,
SUPABASE_SHARED_CLIENT_ID,
SUPABASE_SHARED_CLIENT_SECRET,
SLACK_SHARED_CLIENT_ID,
SLACK_SHARED_CLIENT_SECRET,
TELEGRAM_SHARED_BOT_TOKEN,
Expand Down Expand Up @@ -3542,6 +3544,9 @@ async def _initialize_external_libraries(self) -> None:
# HubSpot (only used by the `invite` OAuth path; Private App token login needs nothing)
"HUBSPOT_SHARED_CLIENT_ID": HUBSPOT_SHARED_CLIENT_ID,
"HUBSPOT_SHARED_CLIENT_SECRET": HUBSPOT_SHARED_CLIENT_SECRET,
# Supabase (only used by the OAuth path; personal-access-token login needs nothing)
"SUPABASE_SHARED_CLIENT_ID": SUPABASE_SHARED_CLIENT_ID,
"SUPABASE_SHARED_CLIENT_SECRET": SUPABASE_SHARED_CLIENT_SECRET,
# Slack (only used by the `invite` OAuth path)
"SLACK_SHARED_CLIENT_ID": SLACK_SHARED_CLIENT_ID,
"SLACK_SHARED_CLIENT_SECRET": SLACK_SHARED_CLIENT_SECRET,
Expand Down
9 changes: 9 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -654,3 +654,12 @@ def detect_and_save_os_language() -> str:
HUBSPOT_SHARED_CLIENT_SECRET: str = get_credential(
"hubspot", "client_secret", "HUBSPOT_SHARED_CLIENT_SECRET"
)

# Supabase (confidential OAuth app + PKCE; empty until the app is registered,
# in which case the integration offers only personal-access-token login)
SUPABASE_SHARED_CLIENT_ID: str = get_credential(
"supabase", "client_id", "SUPABASE_SHARED_CLIENT_ID"
)
SUPABASE_SHARED_CLIENT_SECRET: str = get_credential(
"supabase", "client_secret", "SUPABASE_SHARED_CLIENT_SECRET"
)
5 changes: 3 additions & 2 deletions app/data/action/integrations/integration_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def list_available_integrations(input_data: dict) -> dict:
"description": (
"The integration to connect, using its exact id. Valid values: slack, "
"discord, telegram, whatsapp, whatsapp_business, notion, linkedin, "
"posthog, and "
"posthog, supabase, and "
"the Google Workspace apps as SEPARATE ids — gmail, google_drive, "
"google_docs, google_calendar, google_youtube (there is no single "
"'google' integration). Call list_available_integrations if unsure."
Expand All @@ -132,7 +132,8 @@ def list_available_integrations(input_data: dict) -> dict:
"whatsapp_business: {access_token, phone_number_id}, "
"notion: {token}, "
"posthog: {api_key, host(optional: 'us'/'eu'/self-hosted URL), "
"project_id(optional)}. "
"project_id(optional)}, "
"supabase: {access_token (personal access token, sbp_…)}. "
"Leave empty for OAuth or interactive (QR code) flows."
),
"example": {"bot_token": "123456:ABC-DEF"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
YouTube,
MicrosoftOutlook,
PostHog,
Supabase,
} from '@ridemountainpig/svgl-react'
import { useTranslation } from 'react-i18next'
import { Button, Badge, ConfirmModal } from '../../components/ui'
Expand Down Expand Up @@ -98,6 +99,7 @@ const SVGL_BY_ID: Record<string, SvglIcon> = {
google_youtube: YouTube,
outlook: MicrosoftOutlook,
posthog: PostHog,
supabase: Supabase,
}

// Integration icon component. Lookup order:
Expand Down Expand Up @@ -1592,6 +1594,11 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool
: isProblem(account) || account.sessionState === 'reconnecting'
? stateLabel(account)
: ''
// Nickname first (staged edit wins); the identity can be an
// opaque id (e.g. a token fingerprint), so it stays in the tooltip.
const nickname = staged && account.identity in staged.aliases
? staged.aliases[account.identity]
: account.alias
return (
<button
key={account.identity}
Expand All @@ -1603,7 +1610,7 @@ export function IntegrationsSettings({ hideHeader = false }: { hideHeader?: bool
>
<span className={`${styles.mLive} ${dotClass(account)}`} />
<span className={styles.mRowId} title={account.identity}>
{account.identity}
{nickname || account.identity}
</span>
<span className={styles.mRowSpacer} />
{meta && <span className={styles.mRowMeta}>{meta}</span>}
Expand Down
5 changes: 3 additions & 2 deletions craftos_integrations/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# craftos_integrations

A plug-and-play package of 24 external integrations (Discord, Slack, Telegram Bot + User, GitHub, Jira, Stripe, HubSpot, Notion, LinkedIn, Outlook, PostHog, Twitter, WhatsApp Web/Business, LINE, Lark + Lark Calendar/Drive, plus per-service Google: Gmail / Calendar / Drive / Docs / YouTube) that any Python host can drop in.
A plug-and-play package of 25 external integrations (Discord, Slack, Telegram Bot + User, GitHub, Jira, Stripe, HubSpot, Notion, LinkedIn, Outlook, PostHog, Supabase, Twitter, WhatsApp Web/Business, LINE, Lark + Lark Calendar/Drive, plus per-service Google: Gmail / Calendar / Drive / Docs / YouTube) that any Python host can drop in.

The package owns:

Expand Down Expand Up @@ -195,6 +195,7 @@ Every OAuth-capable integration reads its credentials via `ConfigStore.get_oauth
| linkedin | oauth | `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET` |
| notion | both | `NOTION_SHARED_CLIENT_ID`, `NOTION_SHARED_CLIENT_SECRET` (only for `invite`) |
| slack | both | `SLACK_SHARED_CLIENT_ID`, `SLACK_SHARED_CLIENT_SECRET` (only for `invite`) |
| supabase | token → both | `SUPABASE_SHARED_CLIENT_ID`, `SUPABASE_SHARED_CLIENT_SECRET` (OAuth path; `auth_type` reports `both` only once both are set — personal access token otherwise) |

The discord voice helper additionally reads `extras["openai_api_key"]` (or `OPENAI_API_KEY` env) for STT/TTS.

Expand Down Expand Up @@ -381,7 +382,7 @@ integration_registry() # snapshot dict {id: metadata}
| `INTEGRATION.md` | gotchas: identifier shapes, auth failure modes, rate limits |
| `GUIDANCE.md` | how the agent should *use* the integration |

Add the provider to `default_providers()`; the autoloader imports `client.py` at startup. This is the **full port** shape — gmail, google_*, hubspot, linkedin, notion, outlook, slack, posthog. Build every new integration this way.
Add the provider to `default_providers()`; the autoloader imports `client.py` at startup. This is the **full port** shape — gmail, google_*, hubspot, linkedin, notion, outlook, slack, posthog, supabase. Build every new integration this way.

#### The older two-folder shape (auth-layer bridge)

Expand Down
24 changes: 24 additions & 0 deletions craftos_integrations/core/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,10 +210,34 @@ def store_credential(
self, provider_id: str, identity: Optional[str], credential: Dict[str, Any]
) -> str:
"""OAuth-completion write path (used by add_account / re-auth)."""
known = {a.identity for a in self.accounts.list_accounts(provider_id)}
stored = self.accounts.upsert_account(provider_id, identity, credential)
if stored not in known:
self._apply_default_alias(provider_id, stored, credential)
self.registry.invalidate(provider_id, stored)
return stored

def _apply_default_alias(
self, provider_id: str, identity: str, credential: Dict[str, Any]
) -> None:
"""Name a NEW account with the provider's suggestion, if it has one.

For providers whose identity is opaque (a token fingerprint, a
numeric id) — the user sees a name instead of a hash. Never
overwrites: runs only on first store, and a suggestion that is
taken or invalid is skipped; the user can always rename.
"""
provider = self.registry.get(provider_id)
suggest = getattr(provider, "default_alias", None)
if suggest is None:
return
try:
alias = suggest(credential)
if alias:
self.accounts.set_alias(provider_id, identity, alias)
except Exception as exc: # taken alias, provider bug — never fatal
logger.info(f"[ACCOUNTS] {provider_id}: default alias skipped ({exc})")

def update_credential(
self, provider_id: str, identity: str, credential: Dict[str, Any]
) -> None:
Expand Down
2 changes: 2 additions & 0 deletions craftos_integrations/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ def default_providers() -> List[Provider]:
from .posthog import PostHogProvider
from .slack import SlackProvider
from .stripe import StripeProvider
from .supabase import SupabaseProvider
from .telegram_bot import TelegramBotProvider
from .telegram_user import TelegramUserProvider
from .twitter import TwitterProvider
Expand All @@ -55,6 +56,7 @@ def default_providers() -> List[Provider]:
OutlookProvider(),
PostHogProvider(),
SlackProvider(),
SupabaseProvider(),
# Auth-layer bridges — multi-account storage/UI/listeners; the
# action surface stays, made account-aware centrally
# (see app/data/action/integrations/account_bridge.py).
Expand Down
136 changes: 136 additions & 0 deletions craftos_integrations/providers/supabase/GUIDANCE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Using Supabase

## Load the set that owns the job

The `supabase` action set is a shortlist — it does NOT contain bucket
management, secrets, user create/update/delete, branches or project
settings. Before doing any of those, load the set that has them:

| Job | Load |
|---|---|
| Buckets (create / update / empty / delete), move / copy files, signed URLs | `supabase_storage` |
| Edge-function secrets (list / set / delete) | `supabase_secrets` |
| Auth users (create / update / ban / delete / invite / magic links) | `supabase_auth` |
| Functions beyond deploy / invoke / delete (metadata, verify_jwt) | `supabase_functions` |
| Migrations history, rollback on branches | `supabase_migrations` |
| Projects: create / rename / pause / restore / settings, organizations | `supabase_projects` |
| Branches | `supabase_branches` |
| Logs beyond the default, performance advisors, API usage | `supabase_monitoring` |

**Never work around a missing operation with SQL** on Supabase's own schemas:
`storage.*` (Supabase blocks deleting buckets/objects there — it fails), `auth.*`
(skips Auth's own cleanup), or `vault.*`. Edge-function secrets are **not** in
Vault — they're only reachable through `supabase_secrets`. If an operation
seems missing, the answer is to load its set, not to tell the user to use the
dashboard.

## Find the project first

Almost every operation takes a `project_ref` — the 20-letter id of a Supabase
project. If the user hasn't named one and no default is configured, call
`list_supabase_projects` and match their words against project names. If
several fit, ask. Projects with status `INACTIVE` are paused: their data APIs
are offline until `restore_supabase_project`.

## Look before you query

Before reading or writing data in an unfamiliar project, call
`list_supabase_tables`, then `describe_supabase_table` on the tables you need.
Guessing column names produces errors and, worse, wrong filters.

## Reading data

- Questions ("how many orders last week", "top customers"): write SQL and
run it with **`run_supabase_sql_readonly`**. It cannot change anything, so
use it freely. Pass user-supplied values through `parameters` ($1, $2) —
never paste them into the SQL string.
- Fetching specific rows to show or edit: `select_supabase_rows` with
PostgREST filters (`{"status": "eq.open"}`), `order`, `limit`. Follow
`next_offset` to page.
- `select_supabase_rows` only reaches schemas exposed to the API (usually
`public`). For `auth`, `storage` or private schemas use SQL.

## Changing data

All row, storage and user operations run with the project's service role:
**Row Level Security does not protect anything here.** So:

- `update_supabase_rows` / `delete_supabase_rows` need `filters`. Run the same
filters through `select_supabase_rows` (with `count: true`) first and tell
the user how many rows will change before changing more than a handful.
- `insert_supabase_rows` returns the inserted rows — report the new ids.
- Use `run_supabase_sql` only for changes the row operations can't express.

## Changing the schema

Use **`apply_supabase_migration`** for any DDL — create/alter table, indexes,
RLS policies, functions, extensions — with a snake_case `name`. To undo a
change on a production project, apply a new migration that reverses it
(`rollback_supabase_migrations` only works on development branches). It is recorded in migration history, which keeps the
project in sync with the user's local `supabase/migrations`. Don't use
`run_supabase_sql` for DDL unless the user asks for an untracked change.

After creating a table, enable RLS and add policies in the same migration
unless the user says otherwise, then run `get_supabase_security_advisors` —
it flags tables left open.

## Building an app on Supabase

`get_supabase_project_keys` returns the project URL and the publishable key —
the two values a frontend needs. The secret key is never available to you and
must never be put in client code. `generate_supabase_typescript_types` gives
typed table definitions for a TypeScript app.

A brand-new table may take a couple of seconds to become visible to the row
operations — they retry automatically, so just call them.

## Storage

Files live in buckets. `list_supabase_files` lists one folder level; entries
with `is_folder: true` are prefixes to list next. Upload from and download to
absolute local paths. To share a private file, `create_supabase_signed_url`.
`delete_supabase_bucket` only works on an empty bucket — `empty_supabase_bucket`
first, after confirming with the user.

## Users

`list_supabase_users` / `get_supabase_user` are the project's end users, not
Supabase dashboard members (those are `list_supabase_organization_members`).
`invite_supabase_user` sends an email; `create_supabase_user` doesn't. To ban,
`update_supabase_user` with `ban_duration` (`"none"` lifts it). Magic or
recovery links from `generate_supabase_auth_link` are credentials — hand them
only to the user who asked.

## Edge functions

Deploy with `deploy_supabase_function` and `files: {"index.ts": "..."}`;
redeploying the same slug replaces the code. Existing source can't be read
back — if the user wants an edit, ask for the source or find it in their
workspace. Function config goes in secrets (`set_supabase_secrets`, read in
code with `Deno.env.get`). Test with `invoke_supabase_function`, and when it
fails, `get_supabase_logs` with `source: "edge_function_runtime"`.

## Debugging a project

1. `get_supabase_project_health` — is a service down?
2. `get_supabase_logs` — `postgres` for database errors, `api` for HTTP
errors, `auth` for login problems; narrow with `search`.
3. `get_supabase_security_advisors` / `get_supabase_performance_advisors`.

## Things that need a yes from the user first

Deleting or pausing a project, `rollback_supabase_migrations`,
`empty_supabase_bucket`, deleting users, `create_supabase_project` and
`create_supabase_branch` (both can cost money), and any bulk row change.

## Read-only mode

If an operation fails with "read-only mode", the user has switched the
integration to read-only in Settings. Tell them; don't look for a workaround.

## Multiple accounts

A personal-access-token account sees every organization its user belongs to.
An OAuth account sees only the organization chosen when it was connected —
to add a different organization, connect again and pick that organization on
Supabase's consent screen.
Loading