@fayz-ai/db 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/canonical-schema.json +132 -0
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +3 -2
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -36
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -81
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
@@ -1,266 +0,0 @@
1
- -- ============================================================================
2
- -- 027_domain_events.sql — the facts the browser never sees.
3
- --
4
- -- Domain events have exactly one emitter today: the CRUD store running in the
5
- -- browser (packages/admin/src/stores/createCrudStore.ts). It only knows about
6
- -- writes that pass through it. An edge function's upsert, a CSV import, a
7
- -- webhook handler, a hand-run SQL fix — none of them produce a fact, and those
8
- -- are precisely the writes an integration makes. So "notify me when a booking
9
- -- is created" quietly stops being true the moment bookings start arriving from
10
- -- outside the app, which is the whole point of having integrations.
11
- --
12
- -- This puts the emit seam where every write ends up no matter who made it:
13
- -- `plg_emit_event` records one fact, `plg_attach_event_source` hangs a generic
14
- -- trigger on a table. It lives in the core db package and not in
15
- -- plugin-automations because `emitsFrom` is a manifest field ANY plugin may
16
- -- declare — the ledger must not depend on an optional plugin being installed.
17
- --
18
- -- The ledger table is therefore (re)created here with the SAME shape as
19
- -- plugins/plugin-automations/migrations/0002_event_log.sql: a pool that already
20
- -- ran 0002 sees a no-op, a pool without that plugin gets the table.
21
- --
22
- -- Nothing is attached to a real table here. Declaring `emitsFrom` in a manifest
23
- -- is how a plugin opts in, and that wiring is a later milestone.
24
- -- ============================================================================
25
-
26
- -- ── The ledger ──────────────────────────────────────────────────────────────
27
- -- One row per FACT, not per message sent: the same paid order may drive an
28
- -- e-mail, a webhook and the audit trail, and only a row tied to the fact can
29
- -- answer "did the trigger fire at all?" without asking every consumer.
30
- -- Byte-for-byte the shape of 0002 — divergence here would mean two different
31
- -- tables answering to one name across the pool fleet.
32
- CREATE TABLE IF NOT EXISTS public.plg_event_log (
33
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
34
- tenant_id uuid NOT NULL,
35
- event_key text NOT NULL,
36
- status text NOT NULL DEFAULT 'recorded'
37
- CHECK (status IN ('recorded', 'sent', 'skipped', 'failed')),
38
- payload jsonb NOT NULL DEFAULT '{}'::jsonb,
39
- result jsonb,
40
- error text,
41
- actor_id uuid,
42
- subject_type text,
43
- subject_id text,
44
- occurred_at timestamptz NOT NULL DEFAULT now(),
45
- created_at timestamptz NOT NULL DEFAULT now()
46
- );
47
-
48
- CREATE INDEX IF NOT EXISTS plg_event_log_lookup_idx
49
- ON public.plg_event_log (tenant_id, event_key, occurred_at DESC);
50
-
51
- CREATE INDEX IF NOT EXISTS plg_event_log_subject_idx
52
- ON public.plg_event_log (tenant_id, subject_type, subject_id, occurred_at DESC);
53
-
54
- ALTER TABLE public.plg_event_log ENABLE ROW LEVEL SECURITY;
55
-
56
- DROP POLICY IF EXISTS plg_event_log_member_read ON public.plg_event_log;
57
- CREATE POLICY plg_event_log_member_read ON public.plg_event_log
58
- FOR SELECT TO authenticated
59
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
60
-
61
- DROP POLICY IF EXISTS plg_event_log_member_insert ON public.plg_event_log;
62
- CREATE POLICY plg_event_log_member_insert ON public.plg_event_log
63
- FOR INSERT TO authenticated
64
- WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
65
-
66
- -- No UPDATE/DELETE policy, same as 0002: a history the application can rewrite
67
- -- is not a history. Marking 'sent'/'failed' is the dispatcher's job, server-side.
68
-
69
- -- ── Dedupe key ──────────────────────────────────────────────────────────────
70
- -- The SDK emitter mints its own uuid per event (packages/sdk/src/events.ts) so a
71
- -- retried batch counts as duplicates instead of re-running automations. That id
72
- -- belongs to the emitter's namespace, not to this table: `id` is the ledger's
73
- -- own identity, is NOT NULL and is already filled on every legacy row, so it
74
- -- cannot express "this caller had no dedupe key". A nullable column plus a
75
- -- partial unique index can — emitters that dedupe get it, the trigger below and
76
- -- anything else that has no natural id simply pass NULL.
77
- ALTER TABLE public.plg_event_log ADD COLUMN IF NOT EXISTS event_id uuid;
78
-
79
- CREATE UNIQUE INDEX IF NOT EXISTS plg_event_log_event_id_key
80
- ON public.plg_event_log (event_id) WHERE event_id IS NOT NULL;
81
-
82
- -- ── plg_emit_event ──────────────────────────────────────────────────────────
83
- CREATE OR REPLACE FUNCTION public.plg_emit_event(
84
- p_key text,
85
- p_payload jsonb DEFAULT '{}'::jsonb,
86
- p_subject_type text DEFAULT NULL,
87
- p_subject_id text DEFAULT NULL,
88
- p_tenant_id uuid DEFAULT NULL,
89
- p_event_id uuid DEFAULT NULL
90
- )
91
- RETURNS uuid
92
- LANGUAGE plpgsql
93
- SECURITY DEFINER
94
- SET search_path = public
95
- AS $fn$
96
- DECLARE
97
- v_tenants uuid[];
98
- v_tenant uuid := p_tenant_id;
99
- v_id uuid;
100
- BEGIN
101
- IF p_key IS NULL OR p_key = '' THEN
102
- RAISE EXCEPTION 'plg_emit_event: p_key is required'
103
- USING ERRCODE = 'invalid_parameter_value';
104
- END IF;
105
-
106
- SELECT array_agg(t) INTO v_tenants FROM public.user_tenant_ids() AS t;
107
-
108
- IF v_tenant IS NULL THEN
109
- -- A fact filed under the wrong tenant is worse than a fact not filed: it is
110
- -- readable by people it does not belong to and it triggers their automations.
111
- -- With more than one membership and no explicit tenant there is no answer,
112
- -- only a guess, so refuse.
113
- IF v_tenants IS NULL OR array_length(v_tenants, 1) <> 1 THEN
114
- RAISE EXCEPTION 'plg_emit_event: cannot resolve tenant for % — pass p_tenant_id', p_key
115
- USING ERRCODE = 'invalid_parameter_value';
116
- END IF;
117
- v_tenant := v_tenants[1];
118
- END IF;
119
-
120
- -- SECURITY DEFINER runs as the owner and therefore bypasses the INSERT policy
121
- -- above, so the membership check it would have made has to happen here — the
122
- -- same defect 026 fixed on audit_logs. No auth.uid() means service_role or a
123
- -- migration, i.e. the server, which legitimately writes for any tenant.
124
- IF auth.uid() IS NOT NULL AND NOT (v_tenant = ANY (COALESCE(v_tenants, '{}'::uuid[]))) THEN
125
- RAISE EXCEPTION 'plg_emit_event: caller is not a member of tenant %', v_tenant
126
- USING ERRCODE = 'insufficient_privilege';
127
- END IF;
128
-
129
- BEGIN
130
- INSERT INTO public.plg_event_log
131
- (tenant_id, event_key, payload, actor_id, subject_type, subject_id, event_id)
132
- VALUES
133
- (v_tenant, p_key, COALESCE(p_payload, '{}'::jsonb), auth.uid(),
134
- p_subject_type, p_subject_id, p_event_id)
135
- ON CONFLICT (event_id) WHERE event_id IS NOT NULL DO NOTHING
136
- RETURNING id INTO v_id;
137
- EXCEPTION WHEN OTHERS THEN
138
- -- The write that caused the fact already succeeded. Losing the ledger row is
139
- -- bad; taking the customer's saved booking down with it is worse.
140
- RAISE WARNING '[plg_emit_event] % not recorded: %', p_key, SQLERRM;
141
- RETURN NULL;
142
- END;
143
-
144
- RETURN v_id; -- NULL when p_event_id was already recorded.
145
- END;
146
- $fn$;
147
-
148
- REVOKE ALL ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid) FROM public;
149
- REVOKE ALL ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid) FROM anon;
150
- GRANT EXECUTE ON FUNCTION public.plg_emit_event(text, jsonb, text, text, uuid, uuid)
151
- TO authenticated, service_role;
152
-
153
- -- ── The generic row trigger ─────────────────────────────────────────────────
154
- -- The payload carries id, display name and entity key — never the row. The whole
155
- -- row would put CPF, phone and address inside every webhook by default, and
156
- -- "send everything" is not a decision to make on behalf of whoever installed an
157
- -- integration; whoever needs the rest fetches it by id. Same envelope the
158
- -- browser store emits (see the note in core/src/entity/events.ts), plus
159
- -- `source`, which is how you tell a trigger fact from a store fact when a table
160
- -- is written by both.
161
- CREATE OR REPLACE FUNCTION public.plg_emit_row_event()
162
- RETURNS trigger
163
- LANGUAGE plpgsql
164
- SECURITY DEFINER
165
- SET search_path = public
166
- AS $fn$
167
- DECLARE
168
- v_row jsonb;
169
- v_action text;
170
- BEGIN
171
- IF TG_OP = 'DELETE' THEN
172
- v_row := to_jsonb(OLD);
173
- v_action := 'deleted';
174
- ELSIF TG_OP = 'UPDATE' THEN
175
- -- A touch that changed nothing is not a fact. Without this, any bulk
176
- -- re-save floods the ledger with "updated" and buries the real ones.
177
- IF OLD IS NOT DISTINCT FROM NEW THEN
178
- RETURN NULL;
179
- END IF;
180
- v_row := to_jsonb(NEW);
181
- v_action := 'updated';
182
- ELSE
183
- v_row := to_jsonb(NEW);
184
- v_action := 'created';
185
- END IF;
186
-
187
- BEGIN
188
- PERFORM public.plg_emit_event(
189
- format('registry.%s.%s', TG_ARGV[0], v_action),
190
- jsonb_build_object(
191
- 'record_id', v_row->>'id',
192
- -- Mirrors displayName() in createCrudStore: an event without a readable
193
- -- name reads as "record" in the timeline.
194
- 'record_name', COALESCE(v_row->>'name', v_row->>'title', v_row->>'full_name', v_row->>'id'),
195
- 'entity_key', TG_ARGV[0],
196
- 'source', 'database'
197
- ),
198
- TG_ARGV[0],
199
- v_row->>'id',
200
- NULLIF(v_row->>'tenant_id', '')::uuid,
201
- NULL
202
- );
203
- EXCEPTION WHEN OTHERS THEN
204
- -- AFTER trigger or not, an exception here would roll the row's write back.
205
- -- Observability must never be able to reject a customer's data.
206
- RAISE WARNING '[plg_emit_row_event] % on %: %', v_action, TG_TABLE_NAME, SQLERRM;
207
- END;
208
-
209
- RETURN NULL;
210
- END;
211
- $fn$;
212
-
213
- REVOKE ALL ON FUNCTION public.plg_emit_row_event() FROM public;
214
- REVOKE ALL ON FUNCTION public.plg_emit_row_event() FROM anon;
215
-
216
- -- ── plg_attach_event_source ─────────────────────────────────────────────────
217
- -- Implements PluginEventSource (core/src/types/plugins.ts): table, entityKey,
218
- -- actions. Not granted to `authenticated`: it is SECURITY DEFINER and takes an
219
- -- arbitrary regclass, so an exposed version would let any signed-in user put a
220
- -- trigger on any table in the pool.
221
- CREATE OR REPLACE FUNCTION public.plg_attach_event_source(
222
- p_table regclass,
223
- p_entity_key text,
224
- p_actions text[] DEFAULT ARRAY['created', 'updated', 'deleted']
225
- )
226
- RETURNS void
227
- LANGUAGE plpgsql
228
- SECURITY DEFINER
229
- SET search_path = public
230
- AS $fn$
231
- DECLARE
232
- v_actions text[] := COALESCE(NULLIF(p_actions, '{}'::text[]), ARRAY['created', 'updated', 'deleted']);
233
- v_action text;
234
- v_ops text[] := '{}';
235
- v_trigger text;
236
- BEGIN
237
- IF p_entity_key IS NULL OR p_entity_key = '' THEN
238
- RAISE EXCEPTION 'plg_attach_event_source: p_entity_key is required'
239
- USING ERRCODE = 'invalid_parameter_value';
240
- END IF;
241
-
242
- FOREACH v_action IN ARRAY v_actions LOOP
243
- CASE v_action
244
- WHEN 'created' THEN v_ops := array_append(v_ops, 'INSERT');
245
- WHEN 'updated' THEN v_ops := array_append(v_ops, 'UPDATE');
246
- WHEN 'deleted' THEN v_ops := array_append(v_ops, 'DELETE');
247
- ELSE RAISE EXCEPTION 'plg_attach_event_source: unknown action %', v_action
248
- USING ERRCODE = 'invalid_parameter_value';
249
- END CASE;
250
- END LOOP;
251
-
252
- -- Derived from the table and not from the entity key: one table gets one
253
- -- trigger, so re-attaching replaces instead of stacking a second emitter.
254
- v_trigger := left(replace(replace(p_table::text, '.', '_'), '"', ''), 48) || '_emit_events';
255
-
256
- EXECUTE format('DROP TRIGGER IF EXISTS %I ON %s', v_trigger, p_table::text);
257
- EXECUTE format(
258
- 'CREATE TRIGGER %I AFTER %s ON %s FOR EACH ROW EXECUTE FUNCTION public.plg_emit_row_event(%L)',
259
- v_trigger, array_to_string(v_ops, ' OR '), p_table::text, p_entity_key
260
- );
261
- END;
262
- $fn$;
263
-
264
- REVOKE ALL ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) FROM public;
265
- REVOKE ALL ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) FROM anon;
266
- GRANT EXECUTE ON FUNCTION public.plg_attach_event_source(regclass, text, text[]) TO service_role;
@@ -1,127 +0,0 @@
1
- -- ============================================================================
2
- -- 028_tenant_scoped_token.sql — a connector's reach comes from its token.
3
- --
4
- -- THE EXPOSURE. A pool is ONE Supabase project serving N tenants:
5
- -- cluster-salon-br-01 (gphxclpkbtbucoqclbco) carries beauty-saas,
6
- -- espaco-renova-rio and several real clinics in the same `public` schema. Every
7
- -- plugin edge function builds its client with SUPABASE_SERVICE_ROLE_KEY, and
8
- -- service_role bypasses RLS by definition — so a connector installed by ONE
9
- -- clinic can read EVERY clinic in the cluster. No line of that code is wrong;
10
- -- the credential simply has no tenant in it.
11
- --
12
- -- THE SEAM. `public.user_tenant_ids()` is the single function every RLS policy
13
- -- in the product resolves through (~260 call sites in the SQL tree: db, shop,
14
- -- courses, plugins). Teach it to honour a `tenant_id` claim and a token becomes a
15
- -- tenant boundary — no policy is edited, no table is touched.
16
- --
17
- -- THIS IS HALF THE FEATURE. Nothing mints such a token yet: minting lives in
18
- -- the platform repo (../fayz), not here. Until it does, no request carries the
19
- -- claim, the claim branch is never taken, and the edge functions keep using
20
- -- service_role — which is exactly why this file is safe to apply now. No edge
21
- -- function in this repo is changed to expect a token that cannot be issued.
22
- -- ============================================================================
23
-
24
- -- ── public.user_tenant_ids() ────────────────────────────────────────────────
25
- -- Signature, return type and volatility are unchanged (SETOF uuid, STABLE,
26
- -- SECURITY DEFINER), so every inherited policy keeps compiling and planning as
27
- -- it did. Only the body grows a branch.
28
- --
29
- -- WHERE THE CLAIM LIVES: top-level `tenant_id`, not `app_metadata.tenant_id`.
30
- -- The repo reads top-level claims and only top-level ones today (022
31
- -- `auth.jwt() ->> 'email'`; shop 0019/0023 `->> 'role'`), and GoTrue never puts
32
- -- a top-level `tenant_id` on a session token — only a JWT we signed ourselves
33
- -- can carry one. `app_metadata` would instead ride along on every session of
34
- -- every user whose metadata was ever stamped, silently putting real people on
35
- -- the narrowing branch and cutting a two-tenant user down to one. Supabase's
36
- -- own guidance rules out `user_metadata` outright: the user can write it.
37
- CREATE OR REPLACE FUNCTION public.user_tenant_ids()
38
- RETURNS SETOF uuid
39
- LANGUAGE sql STABLE SECURITY DEFINER
40
- SET search_path = public, pg_temp
41
- AS $$
42
- WITH claim AS (
43
- -- PRESENCE of the key and USABILITY of its value are separate questions.
44
- -- A token that carries `tenant_id` has expressed intent to be narrowed; if
45
- -- the value then fails to parse, the answer is no tenant, never the full
46
- -- session handed back. Only an absent key falls through.
47
- SELECT coalesce(jsonb_exists(auth.jwt(), 'tenant_id'), false) AS present,
48
- auth.jwt() ->> 'tenant_id' AS raw
49
- ),
50
- scoped AS (
51
- -- Shape-checked instead of cast blindly: an RLS policy is not a place to
52
- -- raise, so a junk value must resolve to no rows rather than an error.
53
- SELECT raw::uuid AS tenant_id
54
- FROM claim
55
- WHERE raw ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
56
- )
57
- -- No claim at all → the pre-028 behaviour, unchanged. A token with neither a
58
- -- claim nor auth.uid() therefore matches no member row and resolves to
59
- -- nothing, which is the fail-closed answer.
60
- SELECT tm.tenant_id
61
- FROM public.tenant_members tm
62
- WHERE tm.user_id = auth.uid()
63
- AND NOT (SELECT present FROM claim)
64
- UNION
65
- -- Precedence: the claim NARROWS, it never widens. A signed-in session is the
66
- -- stronger authority, so when auth.uid() is present the claim is intersected
67
- -- with real membership rather than trusted — a token bearing someone else's
68
- -- tenant reaches nothing. Only a session-less token (the connector) is scoped
69
- -- by the claim alone, and that token can only exist if we signed it.
70
- SELECT s.tenant_id
71
- FROM scoped s
72
- WHERE auth.uid() IS NULL
73
- OR EXISTS (
74
- SELECT 1 FROM public.tenant_members tm
75
- WHERE tm.user_id = auth.uid() AND tm.tenant_id = s.tenant_id
76
- );
77
- $$;
78
-
79
- -- ── The connector role ──────────────────────────────────────────────────────
80
- -- What a connector may do becomes a property of the role its token lands in.
81
- -- `fayz_connector` is NOT a service_role understudy: RLS applies to it in full,
82
- -- and with the claim above that means exactly one tenant. Named for the `fayz_`
83
- -- prefix the tree already uses for platform-owned objects (fayz_projects,
84
- -- fayz_migration_ledger, fayz_global_search).
85
- DO $$
86
- BEGIN
87
- IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_connector') THEN
88
- CREATE ROLE fayz_connector NOLOGIN NOINHERIT;
89
- END IF;
90
- END $$;
91
-
92
- -- PostgREST SET ROLEs into the role named by the token's `role` claim, which it
93
- -- can only do if the authenticator it connects as is a member. Guarded because
94
- -- a bare Postgres (the migration bench) has no authenticator role.
95
- DO $$
96
- BEGIN
97
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticator') THEN
98
- EXECUTE 'GRANT fayz_connector TO authenticator';
99
- END IF;
100
- END $$;
101
-
102
- GRANT USAGE ON SCHEMA public TO fayz_connector;
103
-
104
- -- NO DIRECT TABLE ACCESS, now and for tables added later. 008 grants tables to
105
- -- authenticated/service_role only, so this is already true by omission — stated
106
- -- explicitly because a silence is not a decision, and because a future blanket
107
- -- grant would otherwise reach this role too. A connector reads through declared
108
- -- functions or it does not read.
109
- REVOKE ALL ON ALL TABLES IN SCHEMA public FROM fayz_connector;
110
- ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE ALL ON TABLES FROM fayz_connector;
111
-
112
- -- The RPC surface is opt-in per function, written next to the function that
113
- -- opens it (`GRANT EXECUTE ON FUNCTION ... TO fayz_connector`), the way
114
- -- 015/018/022/024 grant themselves. This one is granted here because it is how
115
- -- a connector answers "which tenant am I?", and it can only ever return the
116
- -- caller's own scope.
117
- --
118
- -- What is NOT done here, deliberately: PUBLIC keeps its default EXECUTE on this
119
- -- function. RLS policy expressions run as the QUERYING role, so revoking it
120
- -- would turn anon's storefront catalogue reads into "permission denied for
121
- -- function user_tenant_ids" instead of zero rows — 009 revoked anon's explicit
122
- -- grant and anon still works today precisely because PUBLIC's carries it. The
123
- -- same default means fayz_connector can reach public functions it was never
124
- -- granted; that is a pre-existing repo-wide condition this file neither creates
125
- -- nor widens, and its ceiling is RLS plus the claim above.
126
- GRANT EXECUTE ON FUNCTION public.user_tenant_ids()
127
- TO authenticated, service_role, fayz_connector;
@@ -1,186 +0,0 @@
1
- -- ============================================================================
2
- -- 029_connections.sql — one shape for every connector.
3
- --
4
- -- Four connectors exist today and each brought its own pair of tables
5
- -- (plg_marketing_ranklayer_integrations, bank_integrations, calendar_integrations,
6
- -- + a *_sync_log each). They are the same table under four names: tenant, on/off,
7
- -- settings, last sync. At 100 connectors that is 200 tables nobody can audit.
8
- --
9
- -- Two tables replace the pair. They live in packages/db and not in a plugin
10
- -- because a connector must be able to ship with a ConnectorDefinition and no SQL
11
- -- at all — a table that only exists when some plugin happens to be installed
12
- -- cannot be that.
13
- --
14
- -- The two levels the shape encodes, and they do not flatten (FAY-1376 item 1):
15
- -- connection = ONE account at the provider (one grant of credentials)
16
- -- stream = one resource inside it (a calendar, a bank account)
17
- -- Two Google accounts are two connections; two calendars are two streams of one
18
- -- connection. `instance_key` is what separates the first case; `cursors` is
19
- -- keyed by the second.
20
- --
21
- -- SUPERSEDES nothing yet (still true 2026-08-12): the live pool's connections
22
- -- all sit on bespoke tables and this table is EMPTY there. The bespoke tables
23
- -- die one ticket each — bank_integrations (FAY-1360), the RankLayer leftovers
24
- -- (FAY-1391), and plg_calendar_integrations LAST (FAY-1359): it holds a real
25
- -- clinic's OAuth refresh token, whose destination is the platform's encrypted
26
- -- ConnectorConnection store (FAY-1388), never a column here. Adding a token
27
- -- column to this table instead would put every provider's token for every
28
- -- tenant of a shared pool in one place: strictly worse than today's dispersion.
29
- -- ============================================================================
30
-
31
- -- ── The connection ──────────────────────────────────────────────────────────
32
- -- No credential column, by construction — a connector cannot store a secret here
33
- -- because there is nowhere to put one; convention failed three times already.
34
- CREATE TABLE IF NOT EXISTS public.plg_connections (
35
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
36
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
37
- connector_id text NOT NULL,
38
- instance_key text NOT NULL DEFAULT '', -- bank account, Bling company, gateway instance
39
- display_name text,
40
- active boolean NOT NULL DEFAULT true, -- the tenant's switch
41
- status text NOT NULL DEFAULT 'pending' -- health, observed server-side
42
- CHECK (status IN ('pending', 'connected', 'error', 'revoked')),
43
- status_detail text,
44
- settings jsonb NOT NULL DEFAULT '{}'::jsonb, -- declared keys only; never a credential
45
- provider_account_key text,
46
- cursors jsonb NOT NULL DEFAULT '{}'::jsonb, -- {stream: cursor}
47
- last_sync_at timestamptz,
48
- created_at timestamptz NOT NULL DEFAULT now(),
49
- updated_at timestamptz NOT NULL DEFAULT now(),
50
- UNIQUE (tenant_id, connector_id, instance_key)
51
- );
52
-
53
- -- Webhook routing: the provider announces an account, not a tenant. Partial so
54
- -- the connectors that never learn an account key (api-key ones) are unaffected.
55
- CREATE UNIQUE INDEX IF NOT EXISTS uq_plg_connections_account_key
56
- ON public.plg_connections (connector_id, provider_account_key)
57
- WHERE provider_account_key IS NOT NULL;
58
-
59
- COMMENT ON TABLE public.plg_connections IS
60
- 'One tenant''s connection to one account at one provider. Has NO credential '
61
- 'column by construction: secrets belong to the platform credential store, '
62
- 'never to a row a connector''s own browser code can write.';
63
-
64
- -- ── The run ────────────────────────────────────────────────────────────────
65
- -- One row per execution of the SYNC plane. An invocation of the `request` plane
66
- -- (a shipping quote, a checkout session) is NOT a run: it would be the largest
67
- -- table in the pool within weeks, it is issued for an `anon` buyer with no
68
- -- tenant-scoped write path, and it has no fetched/written/cursor to record.
69
- CREATE TABLE IF NOT EXISTS public.plg_sync_runs (
70
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
71
- tenant_id uuid NOT NULL,
72
- connection_id uuid NOT NULL REFERENCES public.plg_connections(id) ON DELETE CASCADE,
73
- connector_id text NOT NULL,
74
- direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
75
- -- `trigger_kind`, never `trigger`: the reserved word already cost the banking
76
- -- plugin one migration.
77
- trigger_kind text NOT NULL CHECK (trigger_kind IN ('on-write', 'scheduled', 'manual', 'webhook')),
78
- status text NOT NULL CHECK (status IN ('running', 'success', 'partial', 'error')),
79
- stream text,
80
- fetched integer NOT NULL DEFAULT 0,
81
- written integer NOT NULL DEFAULT 0,
82
- stats jsonb NOT NULL DEFAULT '{}'::jsonb, -- duplicates, discovered, period_from/to
83
- cursor text,
84
- correlation_id uuid,
85
- error text,
86
- started_at timestamptz NOT NULL DEFAULT now(),
87
- finished_at timestamptz
88
- );
89
-
90
- CREATE INDEX IF NOT EXISTS idx_plg_sync_runs_read
91
- ON public.plg_sync_runs (connection_id, started_at DESC);
92
-
93
- -- ── RLS and the default-privilege trap ─────────────────────────────────────
94
- -- 011 closed `anon` on every table in the schema and for future ones. It did
95
- -- NOT close `authenticated`, which is still born with the full set on any new
96
- -- public table. Here that would be every tenant reading every tenant's
97
- -- provider_account_key — the bank-account instance key of every clinic in the
98
- -- pool. So both tables are stripped first and granted back only what they need.
99
- REVOKE ALL ON public.plg_connections FROM anon, authenticated;
100
- REVOKE ALL ON public.plg_sync_runs FROM anon, authenticated;
101
-
102
- ALTER TABLE public.plg_connections ENABLE ROW LEVEL SECURITY;
103
- ALTER TABLE public.plg_sync_runs ENABLE ROW LEVEL SECURITY;
104
-
105
- GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_connections TO authenticated;
106
-
107
- -- SELECT only. A history the application can write is not a history: any member
108
- -- could otherwise forge "sync succeeded" on a shared audit table. Runs are
109
- -- written server-side (service_role), the same rule plg_event_log follows.
110
- GRANT SELECT ON public.plg_sync_runs TO authenticated;
111
-
112
- DROP POLICY IF EXISTS plg_connections_member_read ON public.plg_connections;
113
- CREATE POLICY plg_connections_member_read ON public.plg_connections
114
- FOR SELECT TO authenticated
115
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
116
-
117
- DROP POLICY IF EXISTS plg_connections_member_insert ON public.plg_connections;
118
- CREATE POLICY plg_connections_member_insert ON public.plg_connections
119
- FOR INSERT TO authenticated
120
- WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
121
-
122
- DROP POLICY IF EXISTS plg_connections_member_update ON public.plg_connections;
123
- CREATE POLICY plg_connections_member_update ON public.plg_connections
124
- FOR UPDATE TO authenticated
125
- USING (tenant_id IN (SELECT public.user_tenant_ids()))
126
- WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
127
-
128
- DROP POLICY IF EXISTS plg_connections_member_delete ON public.plg_connections;
129
- CREATE POLICY plg_connections_member_delete ON public.plg_connections
130
- FOR DELETE TO authenticated
131
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
132
-
133
- DROP POLICY IF EXISTS plg_sync_runs_member_read ON public.plg_sync_runs;
134
- CREATE POLICY plg_sync_runs_member_read ON public.plg_sync_runs
135
- FOR SELECT TO authenticated
136
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
137
-
138
- -- No INSERT/UPDATE/DELETE policy on plg_sync_runs, deliberately: the missing
139
- -- grant above and the missing policy here have to agree, or the next person to
140
- -- "fix the grant" reopens it.
141
-
142
- -- ── Retention ──────────────────────────────────────────────────────────────
143
- -- Append-only, N tenants per pool, a scheduled pull every 10 minutes. Every
144
- -- reader wants the last 20 rows; the table wants millions. The prune ships with
145
- -- the table and not in a later ticket, because by the time it is a ticket the
146
- -- pool already has the rows.
147
- --
148
- -- A run is deleted only when it is BOTH outside the newest N of its connection
149
- -- AND older than the window. Either test alone is wrong: newest-N alone erases
150
- -- the morning while someone is debugging it, and age alone empties the panel of
151
- -- a connector that syncs once a quarter.
152
- CREATE OR REPLACE FUNCTION public.plg_prune_sync_runs(
153
- p_keep_per_connection integer DEFAULT 50,
154
- p_keep_days integer DEFAULT 30
155
- )
156
- RETURNS integer
157
- LANGUAGE plpgsql
158
- SECURITY DEFINER
159
- SET search_path = public
160
- AS $fn$
161
- DECLARE
162
- v_rows integer;
163
- BEGIN
164
- WITH ranked AS (
165
- SELECT id,
166
- row_number() OVER (PARTITION BY connection_id ORDER BY started_at DESC) AS rn
167
- FROM public.plg_sync_runs
168
- )
169
- DELETE FROM public.plg_sync_runs r
170
- USING ranked
171
- WHERE r.id = ranked.id
172
- AND ranked.rn > GREATEST(p_keep_per_connection, 1)
173
- AND r.started_at < now() - make_interval(days => GREATEST(p_keep_days, 0));
174
- GET DIAGNOSTICS v_rows = ROW_COUNT;
175
- RETURN v_rows;
176
- END;
177
- $fn$;
178
-
179
- REVOKE ALL ON FUNCTION public.plg_prune_sync_runs(integer, integer) FROM PUBLIC;
180
- REVOKE ALL ON FUNCTION public.plg_prune_sync_runs(integer, integer) FROM anon, authenticated;
181
- GRANT EXECUTE ON FUNCTION public.plg_prune_sync_runs(integer, integer) TO service_role;
182
-
183
- COMMENT ON FUNCTION public.plg_prune_sync_runs(integer, integer) IS
184
- 'Retention for plg_sync_runs: deletes runs that are both outside the newest N '
185
- 'of their connection and older than the window. No pool runs pg_cron, so the '
186
- 'caller is the sync ingress — it prunes the connection it just wrote.';