database/schema.sql
Rama main · réplica de lectura del repositorio original.
create table if not exists public.profiles (
id uuid primary key,
email text,
plan text default 'free',
created_at timestamptz default now()
);
create table if not exists public.mcp_servers (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
name text not null,
description text,
source_url text,
source_type text,
generated_code text,
tools_json jsonb default '[]'::jsonb,
endpoint_map jsonb default '[]'::jsonb,
diagnostics jsonb default '{}'::jsonb,
status text default 'pending',
validation_status text default 'pending',
generation_status text default 'pending',
is_public boolean default false,
hosted_endpoint text,
call_count int default 0,
generation_version int default 1,
protocol_version text default '2024-11-05',
created_at timestamptz default now()
);
create table if not exists public.api_keys (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
name text not null,
key_hash text unique not null,
key_preview text not null,
last_used timestamptz,
created_at timestamptz default now()
);
alter table if exists public.mcp_servers add column if not exists hosted_endpoint text;
alter table if exists public.mcp_servers add column if not exists call_count int default 0;
alter table if exists public.mcp_servers add column if not exists endpoint_map jsonb default '[]'::jsonb;
alter table if exists public.mcp_servers add column if not exists diagnostics jsonb default '{}'::jsonb;
alter table if exists public.mcp_servers add column if not exists status text default 'pending';
alter table if exists public.mcp_servers add column if not exists validation_status text default 'pending';
alter table if exists public.mcp_servers add column if not exists generation_status text default 'pending';
alter table if exists public.mcp_servers add column if not exists generation_version int default 1;
alter table if exists public.mcp_servers add column if not exists protocol_version text default '2024-11-05';
alter table public.mcp_servers alter column tools_json set default '[]'::jsonb;
alter table if exists public.mcp_servers alter column endpoint_map set default '[]'::jsonb;
alter table if exists public.mcp_servers alter column diagnostics set default '{}'::jsonb;
alter table if exists public.mcp_servers alter column status set default 'pending';
alter table if exists public.mcp_servers alter column validation_status set default 'pending';
alter table if exists public.mcp_servers alter column generation_status set default 'pending';
alter table if exists public.mcp_servers alter column protocol_version set default '2024-11-05';
do $$
begin
if exists (
select 1
from information_schema.columns
where table_schema = 'public'
and table_name = 'mcp_servers'
and column_name = 'generation_version'
and data_type <> 'integer'
) then
alter table public.mcp_servers
alter column generation_version drop default,
alter column generation_version type int using (
case
when generation_version::text ~ '^[0-9]+$' then generation_version::text::int
else 1
end
),
alter column generation_version set default 1;
end if;
end $$;
create table if not exists public.tool_call_logs (
id uuid primary key default gen_random_uuid(),
server_id uuid references public.mcp_servers(id) on delete set null,
user_id uuid references public.profiles(id) on delete cascade,
tool_name text,
status text,
method text,
path text,
execution_mode text,
upstream_status int,
trace_id text,
attempt_count int,
error_code text,
error text,
latency_ms int,
created_at timestamptz default now()
);
alter table if exists public.tool_call_logs add column if not exists method text;
alter table if exists public.tool_call_logs add column if not exists path text;
alter table if exists public.tool_call_logs add column if not exists execution_mode text;
alter table if exists public.tool_call_logs add column if not exists upstream_status int;
alter table if exists public.tool_call_logs add column if not exists trace_id text;
alter table if exists public.tool_call_logs add column if not exists attempt_count int;
alter table if exists public.tool_call_logs add column if not exists error_code text;
alter table if exists public.tool_call_logs add column if not exists error text;
create table if not exists public.api_credentials (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
server_id uuid references public.mcp_servers(id) on delete cascade,
name text not null,
provider text,
auth_scheme text not null,
client_id text,
client_secret_ciphertext text,
injection_name text,
scopes jsonb default '[]'::jsonb,
secret_ciphertext text not null,
access_token_ciphertext text,
refresh_token_ciphertext text,
token_url text,
expires_at timestamptz,
key_preview text,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
alter table if exists public.api_credentials add column if not exists server_id uuid references public.mcp_servers(id) on delete cascade;
alter table if exists public.api_credentials add column if not exists provider text;
alter table if exists public.api_credentials add column if not exists client_id text;
alter table if exists public.api_credentials add column if not exists client_secret_ciphertext text;
alter table if exists public.api_credentials add column if not exists injection_name text;
alter table if exists public.api_credentials add column if not exists scopes jsonb default '[]'::jsonb;
alter table if exists public.api_credentials add column if not exists access_token_ciphertext text;
alter table if exists public.api_credentials add column if not exists refresh_token_ciphertext text;
alter table if exists public.api_credentials add column if not exists token_url text;
alter table if exists public.api_credentials add column if not exists expires_at timestamptz;
alter table if exists public.api_credentials add column if not exists revocation_url text;
alter table if exists public.api_credentials add column if not exists revocation_status text;
alter table if exists public.api_credentials add column if not exists revocation_error text;
alter table if exists public.api_credentials add column if not exists revocation_attempted_at timestamptz;
create table if not exists public.mcp_bundles (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
name text not null,
hosted_endpoint text,
is_public boolean default false,
created_at timestamptz default now()
);
create table if not exists public.mcp_bundle_servers (
bundle_id uuid references public.mcp_bundles(id) on delete cascade,
server_id uuid references public.mcp_servers(id) on delete cascade,
created_at timestamptz default now(),
primary key (bundle_id, server_id)
);
create index if not exists idx_tool_call_logs_server_created_at
on public.tool_call_logs (server_id, created_at desc);
create index if not exists idx_tool_call_logs_user_created_at
on public.tool_call_logs (user_id, created_at desc);
create index if not exists idx_tool_call_logs_trace_id
on public.tool_call_logs (trace_id);
create index if not exists idx_api_credentials_user_server
on public.api_credentials (user_id, server_id);
create index if not exists idx_mcp_bundles_user_created_at
on public.mcp_bundles (user_id, created_at desc);
create index if not exists idx_mcp_bundle_servers_server
on public.mcp_bundle_servers (server_id);
alter table public.profiles enable row level security;
alter table public.mcp_servers enable row level security;
alter table public.api_keys enable row level security;
alter table public.tool_call_logs enable row level security;
alter table public.mcp_bundles enable row level security;
alter table public.mcp_bundle_servers enable row level security;
alter table public.api_credentials enable row level security;
drop policy if exists "profiles are owned by users" on public.profiles;
drop policy if exists "servers are owned by users" on public.mcp_servers;
drop policy if exists "public servers are readable" on public.mcp_servers;
drop policy if exists "api keys are owned by users" on public.api_keys;
drop policy if exists "tool call logs are owned by users" on public.tool_call_logs;
drop policy if exists "bundles are owned by users" on public.mcp_bundles;
drop policy if exists "bundle servers follow bundle ownership" on public.mcp_bundle_servers;
drop policy if exists "credentials are owned by users" on public.api_credentials;
create policy "profiles are owned by users"
on public.profiles for all
using (auth.uid() = id)
with check (auth.uid() = id);
create policy "servers are owned by users"
on public.mcp_servers for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "public servers are readable"
on public.mcp_servers for select
using (is_public = true or auth.uid() = user_id);
create policy "api keys are owned by users"
on public.api_keys for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "tool call logs are owned by users"
on public.tool_call_logs for select
using (auth.uid() = user_id);
create policy "bundles are owned by users"
on public.mcp_bundles for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
create policy "bundle servers follow bundle ownership"
on public.mcp_bundle_servers for all
using (
exists (
select 1 from public.mcp_bundles
where public.mcp_bundles.id = public.mcp_bundle_servers.bundle_id
and public.mcp_bundles.user_id = auth.uid()
)
)
with check (
exists (
select 1 from public.mcp_bundles
where public.mcp_bundles.id = public.mcp_bundle_servers.bundle_id
and public.mcp_bundles.user_id = auth.uid()
)
);
create policy "credentials are owned by users"
on public.api_credentials for select
using (auth.uid() = user_id);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email)
on conflict (id) do nothing;
return new;
end;
$$;
-- Profiles are created by Astrail after Neon Auth verifies the session. During
-- migration the profile ID may be relinked from a legacy identity by email.
create table if not exists public.billing_webhook_events (
id uuid primary key default gen_random_uuid(),
dodo_event_id text unique not null,
event_type text not null default 'unknown',
user_id uuid references public.profiles(id) on delete set null,
event_created_at timestamptz,
processing_result text not null default 'received',
payload jsonb not null,
processed_at timestamptz not null default now()
);
create table if not exists public.billing_subscriptions (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
dodo_customer_id text,
dodo_subscription_id text,
dodo_payment_id text,
plan text not null,
status text not null default 'unknown',
entitlement_status text not null default 'inactive',
paid_confirmed_at timestamptz,
current_period_start timestamptz,
current_period_end timestamptz,
cancel_at_period_end boolean not null default false,
last_payment_status text,
last_payment_at timestamptz,
dodo_last_event_id text,
dodo_last_event_type text,
dodo_last_event_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (user_id)
);
create table if not exists public.billing_payment_events (
id uuid primary key default gen_random_uuid(),
dodo_event_id text unique not null,
dodo_payment_id text,
user_id uuid references public.profiles(id) on delete set null,
dodo_customer_id text,
dodo_subscription_id text,
plan text,
status text not null default 'unknown',
amount numeric,
currency text,
event_created_at timestamptz,
payload jsonb not null,
processed_at timestamptz not null default now()
);
create table if not exists public.billing_usage (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references public.profiles(id) on delete cascade,
server_id uuid references public.mcp_servers(id) on delete set null,
tool_name text,
usage_type text not null default 'tool_call',
quantity integer not null default 1,
created_at timestamptz not null default now()
);
-- Existing installations may predate these columns. Add them before indexes
-- below reference them so the canonical schema remains safely re-runnable.
alter table if exists public.billing_webhook_events add column if not exists user_id uuid references public.profiles(id) on delete set null;
alter table if exists public.billing_webhook_events add column if not exists event_created_at timestamptz;
alter table if exists public.billing_webhook_events add column if not exists processing_result text not null default 'received';
alter table if exists public.billing_subscriptions add column if not exists entitlement_status text not null default 'inactive';
alter table if exists public.billing_subscriptions add column if not exists paid_confirmed_at timestamptz;
alter table if exists public.billing_subscriptions add column if not exists last_payment_status text;
alter table if exists public.billing_subscriptions add column if not exists last_payment_at timestamptz;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_id text;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_type text;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_at timestamptz;
create index if not exists billing_webhook_events_event_type_idx
on public.billing_webhook_events (event_type);
create index if not exists billing_subscriptions_user_id_idx
on public.billing_subscriptions (user_id);
create index if not exists billing_subscriptions_entitlement_idx
on public.billing_subscriptions (user_id, entitlement_status, paid_confirmed_at);
create index if not exists billing_payment_events_user_created_at_idx
on public.billing_payment_events (user_id, event_created_at desc);
create index if not exists billing_payment_events_payment_id_idx
on public.billing_payment_events (dodo_payment_id);
create index if not exists billing_usage_user_id_created_at_idx
on public.billing_usage (user_id, created_at desc);
create index if not exists tool_call_logs_user_id_created_at_idx
on public.tool_call_logs (user_id, created_at desc);
alter table if exists public.billing_webhook_events enable row level security;
alter table if exists public.billing_subscriptions enable row level security;
alter table if exists public.billing_payment_events enable row level security;
alter table if exists public.billing_usage enable row level security;
alter table if exists public.billing_webhook_events add column if not exists user_id uuid references public.profiles(id) on delete set null;
alter table if exists public.billing_webhook_events add column if not exists event_created_at timestamptz;
alter table if exists public.billing_webhook_events add column if not exists processing_result text not null default 'received';
alter table if exists public.billing_subscriptions add column if not exists entitlement_status text not null default 'inactive';
alter table if exists public.billing_subscriptions add column if not exists paid_confirmed_at timestamptz;
alter table if exists public.billing_subscriptions add column if not exists last_payment_status text;
alter table if exists public.billing_subscriptions add column if not exists last_payment_at timestamptz;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_id text;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_type text;
alter table if exists public.billing_subscriptions add column if not exists dodo_last_event_at timestamptz;
drop policy if exists "Users can read own subscriptions" on public.billing_subscriptions;
create policy "Users can read own subscriptions"
on public.billing_subscriptions for select
using (auth.uid() = user_id);
drop policy if exists "Users can read own billing payment events" on public.billing_payment_events;
create policy "Users can read own billing payment events"
on public.billing_payment_events for select
using (auth.uid() = user_id);
drop policy if exists "Users can read own billing usage" on public.billing_usage;
create policy "Users can read own billing usage"
on public.billing_usage for select
using (auth.uid() = user_id);
create table if not exists public.design_partner_requests (
id uuid primary key default gen_random_uuid(),
name text not null,
email text not null,
company text not null,
role text,
persona text default 'developer' check (persona in ('buyer', 'developer', 'workflow_owner')),
agent_kind text not null,
workflow_goal text,
needed_api text not null,
systems_involved text,
has_api_docs text not null check (has_api_docs in ('yes', 'no')),
api_docs_url_or_notes text,
approval_steps text,
auth_constraints text,
runtime_preference text not null check (runtime_preference in ('hosted', 'exported_code', 'self_hosted')),
urgency text not null check (urgency in ('today', 'this_week', 'exploring')),
status text not null default 'new' check (status in ('new', 'contacted', 'mapped', 'generated', 'tested', 'onboarded', 'success')),
created_at timestamptz default now()
);
alter table if exists public.design_partner_requests add column if not exists role text;
alter table if exists public.design_partner_requests add column if not exists persona text default 'developer';
alter table if exists public.design_partner_requests add column if not exists workflow_goal text;
alter table if exists public.design_partner_requests add column if not exists systems_involved text;
alter table if exists public.design_partner_requests add column if not exists api_docs_url_or_notes text;
alter table if exists public.design_partner_requests add column if not exists approval_steps text;
alter table if exists public.design_partner_requests add column if not exists auth_constraints text;
alter table if exists public.design_partner_requests add column if not exists status text not null default 'new';
create index if not exists idx_design_partner_requests_created_at
on public.design_partner_requests (created_at desc);
create index if not exists idx_design_partner_requests_urgency
on public.design_partner_requests (urgency, created_at desc);
create index if not exists idx_design_partner_requests_preference
on public.design_partner_requests (runtime_preference, created_at desc);
create index if not exists idx_design_partner_requests_persona
on public.design_partner_requests (persona, created_at desc);
alter table if exists public.design_partner_requests enable row level security;
drop policy if exists "public can create design partner requests" on public.design_partner_requests;
drop policy if exists "authenticated users can read design partner requests" on public.design_partner_requests;
create policy "public can create design partner requests"
on public.design_partner_requests for insert
with check (true);
create policy "authenticated users can read design partner requests"
on public.design_partner_requests for select
using (auth.role() = 'authenticated');
alter table if exists public.mcp_servers add column if not exists runtime_policy jsonb;
create table if not exists public.tool_approval_requests (
id uuid primary key default gen_random_uuid(),
server_id uuid references public.mcp_servers(id) on delete cascade not null,
user_id uuid references public.profiles(id) on delete cascade not null,
tool_name text not null,
arguments_ciphertext text not null,
arguments_redacted jsonb not null default '{}'::jsonb,
status text not null default 'pending',
expires_at timestamptz not null default (now() + interval '24 hours'),
decided_at timestamptz,
executed_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists idx_tool_approval_requests_user_status
on public.tool_approval_requests (user_id, status, created_at desc);
create index if not exists idx_tool_approval_requests_server
on public.tool_approval_requests (server_id, created_at desc);
alter table public.tool_approval_requests enable row level security;
drop policy if exists "tool approvals are owned by users" on public.tool_approval_requests;
create policy "tool approvals are owned by users"
on public.tool_approval_requests for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Hosted OAuth connect flow + per-end-user credential scoping (see neon-migration-oauth-connect.sql).
alter table if exists public.api_credentials add column if not exists authorization_url text;
alter table if exists public.api_credentials add column if not exists connect_status text not null default 'active';
alter table if exists public.api_credentials add column if not exists connect_state text;
alter table if exists public.api_credentials add column if not exists connect_state_expires_at timestamptz;
alter table if exists public.api_credentials add column if not exists pkce_verifier_ciphertext text;
alter table if exists public.api_credentials add column if not exists end_user_id text;
alter table if exists public.api_credentials add column if not exists security_scheme text;
alter table if exists public.api_credentials add column if not exists security_binding text;
create unique index if not exists api_credentials_connect_state_key
on public.api_credentials (connect_state)
where connect_state is not null;
create index if not exists api_credentials_server_end_user_idx
on public.api_credentials (server_id, end_user_id);
create index if not exists api_credentials_server_identity_scheme_idx
on public.api_credentials (server_id, end_user_id, security_scheme);
create index if not exists api_credentials_server_identity_binding_idx
on public.api_credentials (server_id, end_user_id, security_scheme, security_binding);
-- Human-readable audit summaries (see neon-migration-runtime-quality.sql).
alter table if exists public.tool_call_logs add column if not exists summary text;
alter table if exists public.tool_call_logs add column if not exists api_key_id uuid;
alter table if exists public.tool_call_logs add column if not exists api_key_name text;
alter table if exists public.tool_call_logs add column if not exists api_key_preview text;
alter table if exists public.tool_call_logs add column if not exists client_name text;
alter table if exists public.tool_call_logs add column if not exists credential_refs jsonb not null default '[]'::jsonb;
alter table if exists public.tool_call_logs add column if not exists bundle_id uuid;
-- Integration hardening: field mappings, audit context, idempotency dedup, schema versions (see neon-migration-integration-hardening.sql).
-- Per-customer field mapping rules applied deterministically at runtime.
alter table if exists public.mcp_servers add column if not exists field_mappings jsonb;
-- Audit-log enrichment: who acted (end user + actor role), what they sent
-- (secret-redacted arguments), and a plain-English summary line.
alter table if exists public.tool_call_logs add column if not exists end_user_id text;
alter table if exists public.tool_call_logs add column if not exists actor_role text;
alter table if exists public.tool_call_logs add column if not exists arguments_redacted jsonb;
alter table if exists public.tool_call_logs add column if not exists summary text;
create index if not exists idx_tool_call_logs_end_user
on public.tool_call_logs (server_id, end_user_id, created_at desc);
create index if not exists idx_tool_call_logs_api_key
on public.tool_call_logs (api_key_id, created_at desc);
create index if not exists idx_tool_call_logs_client
on public.tool_call_logs (client_name, created_at desc);
-- Outbound idempotency: one successful execution per (server, tool, key).
create table if not exists public.tool_execution_dedup (
id uuid primary key default gen_random_uuid(),
server_id uuid not null references public.mcp_servers(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
tool_name text not null,
idempotency_key text not null,
trace_id text,
result_text text not null,
created_at timestamptz not null default now()
);
create unique index if not exists tool_execution_dedup_key
on public.tool_execution_dedup (server_id, tool_name, idempotency_key);
alter table public.tool_execution_dedup enable row level security;
drop policy if exists "tool execution dedup is owned by users" on public.tool_execution_dedup;
create policy "tool execution dedup is owned by users"
on public.tool_execution_dedup for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Tool schema snapshots taken before each spec re-import.
create table if not exists public.tool_schema_versions (
id uuid primary key default gen_random_uuid(),
server_id uuid not null references public.mcp_servers(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
version bigint not null default 0,
tools_json jsonb not null default '[]'::jsonb,
endpoint_map jsonb not null default '[]'::jsonb,
diff jsonb,
tool_count int not null default 0,
created_at timestamptz not null default now()
);
create index if not exists idx_tool_schema_versions_server_created_at
on public.tool_schema_versions (server_id, created_at desc);
alter table public.tool_schema_versions enable row level security;
drop policy if exists "tool schema versions are owned by users" on public.tool_schema_versions;
create policy "tool schema versions are owned by users"
on public.tool_schema_versions for all
using (auth.uid() = user_id)
with check (auth.uid() = user_id);
-- Integration operations: OAuth consent, mapping/reliability config, webhooks, schema drift, and TCO tracking.
alter table if exists public.mcp_servers add column if not exists field_mappings jsonb default '{}'::jsonb;
alter table if exists public.mcp_servers alter column field_mappings set default '{}'::jsonb;
alter table if exists public.mcp_servers add column if not exists execution_policy jsonb not null default '{}'::jsonb;
alter table if exists public.mcp_servers add column if not exists schema_fingerprint text;
alter table if exists public.mcp_servers add column if not exists schema_checked_at timestamptz;
alter table if exists public.mcp_servers add column if not exists schema_drift_detected boolean not null default false;
alter table if exists public.api_credentials add column if not exists authorization_url text;
alter table if exists public.api_credentials add column if not exists token_auth_method text not null default 'client_secret_post';
alter table if exists public.api_credentials add column if not exists refresh_lease_id text;
alter table if exists public.api_credentials add column if not exists refresh_lease_until timestamptz;
alter table if exists public.api_keys add column if not exists end_user_id text;
alter table if exists public.api_keys add column if not exists actor_role text;
alter table if exists public.tool_execution_dedup alter column result_text drop not null;
alter table if exists public.tool_execution_dedup add column if not exists status text not null default 'succeeded';
alter table if exists public.tool_execution_dedup add column if not exists lease_expires_at timestamptz;
alter table if exists public.tool_execution_dedup add column if not exists claim_token text;
alter table if exists public.tool_execution_dedup add column if not exists updated_at timestamptz not null default now();
drop function if exists public.claim_tool_execution(uuid, uuid, text, text);
drop function if exists public.claim_tool_execution(uuid, uuid, text, text, text);
create or replace function public.claim_tool_execution(
p_server_id uuid,
p_user_id uuid,
p_tool_name text,
p_idempotency_key text,
p_claim_token text
)
returns table(claim_status text, result_text text, trace_id text, recorded_at timestamptz, owner_token text)
language plpgsql
security invoker
set search_path = public
as $$
declare
claimed public.tool_execution_dedup%rowtype;
begin
insert into public.tool_execution_dedup (
server_id, user_id, tool_name, idempotency_key, status, result_text, claim_token, lease_expires_at, updated_at
) values (
p_server_id, p_user_id, p_tool_name, p_idempotency_key, 'pending', null, p_claim_token, now() + interval '5 minutes', now()
) on conflict (server_id, tool_name, idempotency_key) do nothing
returning * into claimed;
if claimed.id is not null then
return query select 'claimed'::text, null::text, null::text, now(), p_claim_token;
return;
end if;
select * into claimed from public.tool_execution_dedup
where server_id = p_server_id and tool_name = p_tool_name and idempotency_key = p_idempotency_key
for update;
if claimed.status = 'succeeded' then
return query select 'replay'::text, claimed.result_text, claimed.trace_id, claimed.created_at, null::text;
elsif claimed.status = 'failed' then
update public.tool_execution_dedup set status = 'pending', result_text = null, trace_id = null, claim_token = p_claim_token,
lease_expires_at = now() + interval '5 minutes', updated_at = now()
where id = claimed.id;
return query select 'claimed'::text, null::text, null::text, now(), p_claim_token;
elsif claimed.lease_expires_at is not null and claimed.lease_expires_at <= now() then
return query select 'in_doubt'::text, null::text, claimed.trace_id, claimed.updated_at, null::text;
else
return query select 'in_progress'::text, null::text, claimed.trace_id, claimed.updated_at, null::text;
end if;
end;
$$;
revoke all on function public.claim_tool_execution(uuid, uuid, text, text, text) from public, anon, authenticated;
grant execute on function public.claim_tool_execution(uuid, uuid, text, text, text) to admin;
drop policy if exists "tool execution dedup is owned by users" on public.tool_execution_dedup;
create policy "tool execution dedup is owned by users" on public.tool_execution_dedup for all
using (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = tool_execution_dedup.server_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = tool_execution_dedup.server_id and user_id = auth.uid()));
drop policy if exists "tool schema versions are owned by users" on public.tool_schema_versions;
create policy "tool schema versions are owned by users" on public.tool_schema_versions for all
using (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = tool_schema_versions.server_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = tool_schema_versions.server_id and user_id = auth.uid()));
create table if not exists public.webhook_endpoints (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
server_id uuid references public.mcp_servers(id) on delete cascade not null,
name text not null,
secret_ciphertext text not null,
secret_preview text not null,
signature_header text not null default 'x-astrail-signature',
event_id_header text not null default 'x-event-id',
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table if not exists public.webhook_events (
id uuid primary key default gen_random_uuid(),
endpoint_id uuid references public.webhook_endpoints(id) on delete cascade not null,
user_id uuid references public.profiles(id) on delete cascade not null,
event_id text not null,
event_type text,
payload jsonb not null,
headers jsonb not null default '{}'::jsonb,
status text not null default 'received',
received_at timestamptz not null default now(),
unique (endpoint_id, event_id)
);
create table if not exists public.integration_schema_versions (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
server_id uuid references public.mcp_servers(id) on delete cascade not null,
fingerprint text not null,
endpoint_count int not null default 0,
change_summary jsonb not null default '{}'::jsonb,
detected_at timestamptz not null default now(),
unique (server_id, fingerprint)
);
create table if not exists public.integration_cost_events (
id uuid primary key default gen_random_uuid(),
user_id uuid references public.profiles(id) on delete cascade not null,
server_id uuid references public.mcp_servers(id) on delete cascade not null,
category text not null check (category in ('setup', 'maintenance', 'support', 'custom_exception')),
minutes int not null default 0 check (minutes >= 0 and minutes <= 1000000),
amount numeric(12,2) not null default 0 check (amount >= 0),
note text,
created_at timestamptz not null default now()
);
create index if not exists idx_webhook_endpoints_user_server on public.webhook_endpoints (user_id, server_id);
create index if not exists idx_webhook_events_endpoint_received on public.webhook_events (endpoint_id, received_at desc);
create index if not exists idx_webhook_events_user_received on public.webhook_events (user_id, received_at desc);
create index if not exists idx_schema_versions_server_detected on public.integration_schema_versions (server_id, detected_at desc);
create index if not exists idx_integration_costs_user_created on public.integration_cost_events (user_id, created_at desc);
create index if not exists idx_integration_costs_server_created on public.integration_cost_events (server_id, created_at desc);
create index if not exists idx_mcp_servers_schema_watch on public.mcp_servers (source_type, schema_checked_at)
where source_url is not null and source_type in ('url', 'openapi_url');
create or replace view public.integration_cost_totals with (security_invoker = true) as
select user_id, server_id, category, sum(minutes)::bigint as minutes,
sum(amount)::numeric(14,2) as amount, count(*)::bigint as events
from public.integration_cost_events
group by user_id, server_id, category;
alter table public.webhook_endpoints enable row level security;
alter table public.webhook_events enable row level security;
alter table public.integration_schema_versions enable row level security;
alter table public.integration_cost_events enable row level security;
drop policy if exists "webhook endpoints are owned by users" on public.webhook_endpoints;
create policy "webhook endpoints are owned by users" on public.webhook_endpoints for all
using (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = webhook_endpoints.server_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = webhook_endpoints.server_id and user_id = auth.uid()));
drop policy if exists "webhook events are owned by users" on public.webhook_events;
create policy "webhook events are owned by users" on public.webhook_events for all
using (auth.uid() = user_id and exists (select 1 from public.webhook_endpoints where id = webhook_events.endpoint_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.webhook_endpoints where id = webhook_events.endpoint_id and user_id = auth.uid()));
drop policy if exists "schema versions are owned by users" on public.integration_schema_versions;
create policy "schema versions are owned by users" on public.integration_schema_versions for all
using (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = integration_schema_versions.server_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = integration_schema_versions.server_id and user_id = auth.uid()));
drop policy if exists "integration costs are owned by users" on public.integration_cost_events;
create policy "integration costs are owned by users" on public.integration_cost_events for all
using (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = integration_cost_events.server_id and user_id = auth.uid()))
with check (auth.uid() = user_id and exists (select 1 from public.mcp_servers where id = integration_cost_events.server_id and user_id = auth.uid()));
-- Enforce hosted endpoint capacity with atomic per-workspace slot claims.
-- Existing ownership policies remain unchanged; triggers enforce the quota for every write path.
create table if not exists public.hosted_endpoint_slots (
user_id uuid not null references public.profiles(id) on delete cascade,
slot integer not null check (slot > 0),
resource_kind text not null check (resource_kind in ('server', 'bundle')),
resource_id uuid not null,
created_at timestamptz not null default now(),
primary key (user_id, slot),
unique (resource_kind, resource_id)
);
alter table public.hosted_endpoint_slots enable row level security;
create index if not exists idx_mcp_servers_user_hosted_endpoint
on public.mcp_servers (user_id) where hosted_endpoint is not null;
create index if not exists idx_mcp_bundles_user_hosted_endpoint
on public.mcp_bundles (user_id) where hosted_endpoint is not null;
delete from public.hosted_endpoint_slots as slots
where not exists (
select 1 from public.mcp_servers as servers
where slots.resource_kind = 'server'
and servers.id = slots.resource_id
and servers.user_id = slots.user_id
and servers.hosted_endpoint is not null
)
and not exists (
select 1 from public.mcp_bundles as bundles
where slots.resource_kind = 'bundle'
and bundles.id = slots.resource_id
and bundles.user_id = slots.user_id
and bundles.hosted_endpoint is not null
);
do $$
declare
endpoint record;
candidate_slot integer;
begin
for endpoint in
select user_id, resource_kind, resource_id
from (
select user_id, id as resource_id, 'server'::text as resource_kind, created_at
from public.mcp_servers where hosted_endpoint is not null
union all
select user_id, id as resource_id, 'bundle'::text as resource_kind, created_at
from public.mcp_bundles where hosted_endpoint is not null
) as endpoints
where not exists (
select 1 from public.hosted_endpoint_slots as existing
where existing.resource_kind = endpoints.resource_kind
and existing.resource_id = endpoints.resource_id
)
order by user_id, created_at, resource_kind, resource_id
loop
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(endpoint.user_id::text, 948374)
);
candidate_slot := 1;
while exists (
select 1 from public.hosted_endpoint_slots
where user_id = endpoint.user_id and slot = candidate_slot
) loop
candidate_slot := candidate_slot + 1;
end loop;
insert into public.hosted_endpoint_slots (user_id, slot, resource_kind, resource_id)
values (endpoint.user_id, candidate_slot, endpoint.resource_kind, endpoint.resource_id)
on conflict (resource_kind, resource_id) do nothing;
end loop;
end;
$$;
create or replace function public.claim_hosted_endpoint_slot()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
active_plan text;
endpoint_limit integer;
endpoint_count integer;
claimed_slot integer;
candidate_slot integer;
resource_type text;
begin
if new.hosted_endpoint is null then
return new;
end if;
resource_type := case when tg_table_name = 'mcp_servers' then 'server' else 'bundle' end;
if tg_op = 'UPDATE' then
if old.hosted_endpoint is not null and old.user_id = new.user_id then
return new;
end if;
if old.hosted_endpoint is not null and old.user_id <> new.user_id then
delete from public.hosted_endpoint_slots
where user_id = old.user_id
and hosted_endpoint_slots.resource_kind = resource_type
and resource_id = old.id;
end if;
end if;
select subscriptions.plan
into active_plan
from public.billing_subscriptions as subscriptions
where subscriptions.user_id = new.user_id
and subscriptions.entitlement_status = 'active'
and subscriptions.paid_confirmed_at is not null
and subscriptions.status in ('active', 'paid', 'succeeded')
and (subscriptions.current_period_end is null or subscriptions.current_period_end > now())
order by subscriptions.updated_at desc
limit 1;
endpoint_limit := case
when active_plan in ('starter', 'pro', 'builder', 'launch') then 10
when active_plan in ('team', 'scale') then 100
else 3
end;
perform pg_catalog.pg_advisory_xact_lock(
pg_catalog.hashtextextended(new.user_id::text, 948374)
);
select count(*)
into endpoint_count
from public.hosted_endpoint_slots
where user_id = new.user_id;
if endpoint_count >= endpoint_limit then
raise exception using
errcode = 'P0001',
message = 'hosted_endpoint_limit_reached',
detail = 'The workspace has reached its active hosted endpoint limit.';
end if;
for candidate_slot in 1..endpoint_limit loop
insert into public.hosted_endpoint_slots (user_id, slot, resource_kind, resource_id)
values (new.user_id, candidate_slot, resource_type, new.id)
on conflict do nothing
returning slot into claimed_slot;
if claimed_slot is not null then
return new;
end if;
end loop;
if claimed_slot is null then
raise exception using
errcode = 'P0001',
message = 'hosted_endpoint_limit_reached',
detail = 'The workspace has reached its active hosted endpoint limit.';
end if;
return new;
end;
$$;
create or replace function public.release_hosted_endpoint_slot()
returns trigger
language plpgsql
security definer
set search_path = ''
as $$
declare
resource_type text;
begin
resource_type := case when tg_table_name = 'mcp_servers' then 'server' else 'bundle' end;
if tg_op = 'DELETE'
or new.hosted_endpoint is null
or new.user_id <> old.user_id then
delete from public.hosted_endpoint_slots
where user_id = old.user_id
and hosted_endpoint_slots.resource_kind = resource_type
and resource_id = old.id;
end if;
if tg_op = 'DELETE' then
return old;
end if;
return new;
end;
$$;
revoke all on function public.claim_hosted_endpoint_slot() from public;
revoke all on function public.release_hosted_endpoint_slot() from public;
drop trigger if exists enforce_mcp_server_endpoint_limit on public.mcp_servers;
drop trigger if exists release_mcp_server_endpoint_slot on public.mcp_servers;
create trigger enforce_mcp_server_endpoint_limit
before insert or update of hosted_endpoint, user_id on public.mcp_servers
for each row execute function public.claim_hosted_endpoint_slot();
create trigger release_mcp_server_endpoint_slot
after delete or update of hosted_endpoint, user_id on public.mcp_servers
for each row execute function public.release_hosted_endpoint_slot();
drop trigger if exists enforce_mcp_bundle_endpoint_limit on public.mcp_bundles;
drop trigger if exists release_mcp_bundle_endpoint_slot on public.mcp_bundles;
create trigger enforce_mcp_bundle_endpoint_limit
before insert or update of hosted_endpoint, user_id on public.mcp_bundles
for each row execute function public.claim_hosted_endpoint_slot();
create trigger release_mcp_bundle_endpoint_slot
after delete or update of hosted_endpoint, user_id on public.mcp_bundles
for each row execute function public.release_hosted_endpoint_slot();
create table if not exists public.x402_domain_verifications (
id uuid primary key default gen_random_uuid(),
server_id uuid not null references public.mcp_servers(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
domain text not null,
token_hash text not null,
status text not null default 'pending' check (status in ('pending', 'verified', 'failed')),
verified_at timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (server_id, domain)
);
create index if not exists idx_x402_domain_verifications_user
on public.x402_domain_verifications (user_id, status, updated_at desc);
create unique index if not exists idx_mcp_servers_id_user
on public.mcp_servers (id, user_id);
do $$
begin
alter table public.x402_domain_verifications
add constraint x402_domain_verifications_server_owner_fkey
foreign key (server_id, user_id) references public.mcp_servers(id, user_id) on delete cascade;
exception when duplicate_object then null;
end $$;
alter table public.x402_domain_verifications enable row level security;
drop policy if exists "x402 domains are owned by users" on public.x402_domain_verifications;
create policy "x402 domains are owned by users" on public.x402_domain_verifications for all
using (auth.uid() = user_id and exists (
select 1 from public.mcp_servers where id = server_id and user_id = auth.uid()
)) with check (auth.uid() = user_id and exists (
select 1 from public.mcp_servers where id = server_id and user_id = auth.uid()
));
create table if not exists public.x402_payment_challenges (
id uuid primary key default gen_random_uuid(),
server_id uuid not null references public.mcp_servers(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
end_user_id text not null,
tool_name text not null,
requirement_fingerprint text not null check (requirement_fingerprint ~ '^[0-9a-f]{64}$'),
requirement jsonb not null,
expires_at timestamptz not null,
consumed_at timestamptz,
created_at timestamptz not null default now()
);
do $$
begin
alter table public.x402_payment_challenges
add constraint x402_payment_challenges_server_owner_fkey
foreign key (server_id, user_id) references public.mcp_servers(id, user_id) on delete cascade;
exception when duplicate_object then null;
end $$;
create index if not exists idx_x402_challenges_claim
on public.x402_payment_challenges (server_id, end_user_id, tool_name, requirement_fingerprint, expires_at)
where consumed_at is null;
alter table public.x402_payment_challenges enable row level security;
drop policy if exists "x402 challenges are owned by users" on public.x402_payment_challenges;
create policy "x402 challenges are owned by users" on public.x402_payment_challenges for select
using (auth.uid() = user_id and exists (
select 1 from public.mcp_servers where id = server_id and user_id = auth.uid()
));
create or replace function public.record_x402_challenge(
p_server_id uuid, p_user_id uuid, p_end_user_id text, p_tool_name text,
p_requirement_fingerprint text, p_requirement jsonb, p_expires_at timestamptz
)
returns text language plpgsql security definer set search_path = ''
as $$
declare
v_active integer;
begin
if p_end_user_id is null or length(trim(p_end_user_id)) = 0
or p_requirement_fingerprint !~ '^[0-9a-f]{64}$' or p_requirement is null
or octet_length(p_requirement::text) > 8000 or p_expires_at <= now()
or p_expires_at > now() + interval '15 minutes' then return 'invalid'; end if;
if not exists (select 1 from public.mcp_servers where id = p_server_id and user_id = p_user_id and hosted_endpoint is not null) then
return 'forbidden';
end if;
perform pg_advisory_xact_lock(hashtextextended('x402-challenge:' || p_server_id::text || ':' || p_end_user_id || ':' || p_tool_name, 0));
delete from public.x402_payment_challenges where server_id = p_server_id and end_user_id = p_end_user_id
and (expires_at <= now() or consumed_at is not null);
select count(*) into v_active from public.x402_payment_challenges
where server_id = p_server_id and end_user_id = p_end_user_id and tool_name = p_tool_name
and consumed_at is null and expires_at > now();
if v_active >= 32 then return 'limit_exceeded'; end if;
insert into public.x402_payment_challenges (
server_id, user_id, end_user_id, tool_name, requirement_fingerprint, requirement, expires_at
) values (
p_server_id, p_user_id, left(p_end_user_id, 240), left(p_tool_name, 240),
p_requirement_fingerprint, p_requirement, p_expires_at
);
return 'recorded';
end;
$$;
revoke all on function public.record_x402_challenge(uuid, uuid, text, text, text, jsonb, timestamptz) from public;
revoke all on function public.record_x402_challenge(uuid, uuid, text, text, text, jsonb, timestamptz) from anon;
revoke all on function public.record_x402_challenge(uuid, uuid, text, text, text, jsonb, timestamptz) from authenticated;
grant execute on function public.record_x402_challenge(uuid, uuid, text, text, text, jsonb, timestamptz) to admin;
create table if not exists public.x402_payment_receipts (
id uuid primary key default gen_random_uuid(),
server_id uuid not null references public.mcp_servers(id) on delete cascade,
user_id uuid not null references public.profiles(id) on delete cascade,
end_user_id text not null,
tool_name text not null,
trace_id text not null,
payment_fingerprint text not null,
network text not null,
asset text not null,
amount text not null check (amount ~ '^[0-9]{1,78}$'),
pay_to text not null,
status text not null default 'reserved' check (status in ('reserved', 'settled', 'failed', 'in_doubt')),
transaction text,
settlement_response jsonb,
settled_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (server_id, payment_fingerprint)
);
create index if not exists idx_x402_receipts_user_created
on public.x402_payment_receipts (user_id, created_at desc);
create index if not exists idx_x402_receipts_spend_window
on public.x402_payment_receipts (server_id, end_user_id, network, asset, created_at desc)
where status in ('reserved', 'settled', 'in_doubt');
create unique index if not exists idx_x402_receipts_network_fingerprint
on public.x402_payment_receipts (network, payment_fingerprint);
alter table public.x402_payment_receipts enable row level security;
drop policy if exists "x402 receipts are owned by users" on public.x402_payment_receipts;
create policy "x402 receipts are owned by users" on public.x402_payment_receipts for select
using (auth.uid() = user_id);
drop function if exists public.claim_x402_payment(uuid, uuid, text, text, text, text, text, text, text, text, text);
create or replace function public.claim_x402_payment(
p_server_id uuid, p_user_id uuid, p_end_user_id text, p_tool_name text,
p_trace_id text, p_payment_fingerprint text, p_requirement_fingerprint text, p_network text, p_asset text,
p_amount text, p_pay_to text, p_daily_limit text default null
)
returns table(status text, receipt_id uuid)
language plpgsql security definer set search_path = ''
as $$
declare
v_receipt_id uuid;
v_challenge_id uuid;
v_spent numeric := 0;
begin
if p_end_user_id is null or length(trim(p_end_user_id)) = 0
or p_amount !~ '^[0-9]{1,78}$'
or p_payment_fingerprint !~ '^[0-9a-f]{64}$'
or p_requirement_fingerprint !~ '^[0-9a-f]{64}$'
or (p_daily_limit is not null and p_daily_limit !~ '^[0-9]{1,78}$') then
return query select 'invalid'::text, null::uuid; return;
end if;
if not exists (select 1 from public.mcp_servers where id = p_server_id and user_id = p_user_id and hosted_endpoint is not null) then
return query select 'forbidden'::text, null::uuid; return;
end if;
perform pg_advisory_xact_lock(hashtextextended(p_server_id::text || ':' || p_end_user_id || ':' || p_network || ':' || lower(p_asset), 0));
select id into v_receipt_id from public.x402_payment_receipts
where network = p_network and payment_fingerprint = p_payment_fingerprint;
if v_receipt_id is not null then return query select 'duplicate'::text, v_receipt_id; return; end if;
select id into v_challenge_id from public.x402_payment_challenges
where server_id = p_server_id and user_id = p_user_id and end_user_id = p_end_user_id
and tool_name = p_tool_name and requirement_fingerprint = p_requirement_fingerprint
and consumed_at is null and expires_at > now()
order by created_at for update skip locked limit 1;
if v_challenge_id is null then return query select 'challenge_missing'::text, null::uuid; return; end if;
if p_daily_limit is not null then
select coalesce(sum(amount::numeric), 0) into v_spent from public.x402_payment_receipts
where server_id = p_server_id and end_user_id = p_end_user_id and network = p_network
and asset = p_asset and status in ('reserved', 'settled', 'in_doubt')
and created_at >= date_trunc('day', now());
if v_spent + p_amount::numeric > p_daily_limit::numeric then
return query select 'limit_exceeded'::text, null::uuid; return;
end if;
end if;
update public.x402_payment_challenges set consumed_at = now()
where id = v_challenge_id and consumed_at is null;
insert into public.x402_payment_receipts (
server_id, user_id, end_user_id, tool_name, trace_id, payment_fingerprint,
network, asset, amount, pay_to, status
) values (
p_server_id, p_user_id, p_end_user_id, left(p_tool_name, 240), left(p_trace_id, 160), p_payment_fingerprint,
left(p_network, 160), left(p_asset, 300), p_amount, left(p_pay_to, 300), 'reserved'
) returning id into v_receipt_id;
return query select 'claimed'::text, v_receipt_id;
exception when unique_violation then
select id into v_receipt_id from public.x402_payment_receipts
where network = p_network and payment_fingerprint = p_payment_fingerprint;
return query select 'duplicate'::text, v_receipt_id;
end;
$$;
revoke all on function public.claim_x402_payment(uuid, uuid, text, text, text, text, text, text, text, text, text, text) from public;
revoke all on function public.claim_x402_payment(uuid, uuid, text, text, text, text, text, text, text, text, text, text) from anon;
revoke all on function public.claim_x402_payment(uuid, uuid, text, text, text, text, text, text, text, text, text, text) from authenticated;
grant execute on function public.claim_x402_payment(uuid, uuid, text, text, text, text, text, text, text, text, text, text) to admin;