@fayz-ai/db 0.24.2 → 0.25.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.
@@ -0,0 +1,32 @@
1
+ -- ============================================================================
2
+ -- 210_the_service_speaks_as_the_caller — the integration service (ADR 0003,
3
+ -- phase 2) reaches the cluster the way PostgREST does, never with a
4
+ -- service-role key.
5
+ --
6
+ -- PostgREST connects as `authenticator`, a NOINHERIT role that is a member of
7
+ -- anon and authenticated: it holds no privilege of its own and, per request,
8
+ -- `SET LOCAL ROLE`s into the caller's role with the caller's JWT claims, so
9
+ -- RLS and app.has_permission decide exactly as they do for the browser.
10
+ --
11
+ -- fayz_integration becomes the same kind of role. It stays NOINHERIT, so
12
+ -- membership grants nothing until the service switches role inside a
13
+ -- transaction for a request whose JWT it has verified. Machine work (webhooks,
14
+ -- jobs) keeps going through named SECURITY DEFINER functions granted to
15
+ -- fayz_integration directly; service_role is never granted.
16
+ --
17
+ -- Guarded: the role is an operator step and may not exist on a fresh cluster.
18
+ -- Replay-safe: GRANT of an existing membership is a no-op.
19
+ -- ============================================================================
20
+
21
+ DO $$
22
+ BEGIN
23
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN
24
+ ALTER ROLE fayz_integration NOINHERIT;
25
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
26
+ GRANT authenticated TO fayz_integration;
27
+ END IF;
28
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
29
+ GRANT anon TO fayz_integration;
30
+ END IF;
31
+ END IF;
32
+ END $$;
@@ -0,0 +1,77 @@
1
+ -- ============================================================================
2
+ -- 211_the_service_door_for_places_and_connections — named functions the
3
+ -- integration service (ADR 0003 phase 2) calls for machine work that touches
4
+ -- core tables: the postcode cache and a tenant's connector row.
5
+ --
6
+ -- postal_area_get(code) the cached address for a CEP, or null
7
+ -- postal_area_remember(...) caches a looked-up CEP (keys via area_key)
8
+ -- connection_get(tenant, connector, instance)
9
+ -- {id, active, status, settings} or null
10
+ -- connection_settings_merge(id, patch)
11
+ -- merges keys into settings (the provider's
12
+ -- own bookkeeping, e.g. a webhook endpoint id)
13
+ --
14
+ -- SECURITY DEFINER, closed to anon/authenticated, granted to service_role and,
15
+ -- when it exists, fayz_integration. Replay-safe: CREATE OR REPLACE.
16
+ -- ============================================================================
17
+
18
+ CREATE OR REPLACE FUNCTION public.postal_area_get(p_code text) RETURNS jsonb
19
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
20
+ AS $function$
21
+ SELECT jsonb_build_object(
22
+ 'postal_code', a.postal_code, 'street', a.street, 'district', a.district,
23
+ 'city', a.city, 'city_code', a.city_code, 'state', a.state)
24
+ FROM public.postal_areas a
25
+ WHERE a.postal_code = regexp_replace(coalesce(p_code, ''), '\D', '', 'g');
26
+ $function$;
27
+
28
+ CREATE OR REPLACE FUNCTION public.postal_area_remember(
29
+ p_code text, p_street text, p_district text, p_city text, p_city_code text, p_state text, p_source text
30
+ ) RETURNS void
31
+ LANGUAGE sql SECURITY DEFINER SET search_path TO ''
32
+ AS $function$
33
+ INSERT INTO public.postal_areas (postal_code, street, district, district_key, city, city_key, city_code, state, source, fetched_at)
34
+ VALUES (
35
+ regexp_replace(p_code, '\D', '', 'g'), nullif(p_street, ''), nullif(p_district, ''),
36
+ public.area_key(nullif(p_district, '')), p_city, public.area_key(p_city),
37
+ nullif(p_city_code, ''), nullif(upper(p_state), ''), coalesce(p_source, 'viacep'), now())
38
+ ON CONFLICT (postal_code) DO UPDATE
39
+ SET street = excluded.street, district = excluded.district, district_key = excluded.district_key,
40
+ city = excluded.city, city_key = excluded.city_key, city_code = excluded.city_code,
41
+ state = excluded.state, source = excluded.source, fetched_at = excluded.fetched_at;
42
+ $function$;
43
+
44
+ CREATE OR REPLACE FUNCTION public.connection_get(p_tenant uuid, p_connector text, p_instance text DEFAULT '')
45
+ RETURNS jsonb
46
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
47
+ AS $function$
48
+ SELECT jsonb_build_object('id', c.id, 'active', c.active, 'status', c.status, 'settings', coalesce(c.settings, '{}'::jsonb))
49
+ FROM public.connections c
50
+ WHERE c.tenant_id = p_tenant AND c.connector_id = p_connector AND c.instance_key = coalesce(p_instance, '');
51
+ $function$;
52
+
53
+ CREATE OR REPLACE FUNCTION public.connection_settings_merge(p_connection uuid, p_patch jsonb) RETURNS void
54
+ LANGUAGE sql SECURITY DEFINER SET search_path TO ''
55
+ AS $function$
56
+ UPDATE public.connections
57
+ SET settings = coalesce(settings, '{}'::jsonb) || coalesce(p_patch, '{}'::jsonb), updated_at = now()
58
+ WHERE id = p_connection;
59
+ $function$;
60
+
61
+ DO $$
62
+ DECLARE
63
+ f text;
64
+ BEGIN
65
+ FOREACH f IN ARRAY ARRAY[
66
+ 'public.postal_area_get(text)',
67
+ 'public.postal_area_remember(text, text, text, text, text, text, text)',
68
+ 'public.connection_get(uuid, text, text)',
69
+ 'public.connection_settings_merge(uuid, jsonb)'
70
+ ] LOOP
71
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', f);
72
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN EXECUTE format('REVOKE ALL ON FUNCTION %s FROM anon', f); END IF;
73
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN EXECUTE format('REVOKE ALL ON FUNCTION %s FROM authenticated', f); END IF;
74
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', f); END IF;
75
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f); END IF;
76
+ END LOOP;
77
+ END $$;
@@ -0,0 +1,65 @@
1
+ -- ============================================================================
2
+ -- 212_the_service_acts_for_the_store — machine work (a webhook, a scheduled
3
+ -- sync) is done by the integration service AS THE STORE'S OWNER, under RLS,
4
+ -- the same rule shop migration 0119 uses for a server-confirmed payment.
5
+ --
6
+ -- service_actor_for(tenant) answers the earliest active, all-units owner of
7
+ -- the tenant. The service then runs the work in one transaction under
8
+ -- SET LOCAL ROLE authenticated with that user's claims (migration 210), so
9
+ -- policies, permissions and audit columns see a real member, and nothing
10
+ -- bypasses RLS. A tenant with no active owner answers NULL and the service
11
+ -- refuses the work rather than doing it as nobody.
12
+ --
13
+ -- Granted to fayz_integration only (and service_role). Replay-safe.
14
+ -- ============================================================================
15
+
16
+ CREATE OR REPLACE FUNCTION public.service_actor_for(p_tenant uuid) RETURNS uuid
17
+ LANGUAGE sql STABLE SECURITY DEFINER SET search_path TO ''
18
+ AS $function$
19
+ SELECT m.user_id
20
+ FROM app.memberships m
21
+ JOIN app.roles r ON r.id = m.role_id
22
+ WHERE m.tenant_id = p_tenant AND m.active AND m.all_units AND r.key = 'owner'
23
+ ORDER BY m.created_at, m.user_id
24
+ LIMIT 1;
25
+ $function$;
26
+
27
+ DO $$
28
+ BEGIN
29
+ REVOKE ALL ON FUNCTION public.service_actor_for(uuid) FROM PUBLIC;
30
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN REVOKE ALL ON FUNCTION public.service_actor_for(uuid) FROM anon; END IF;
31
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN REVOKE ALL ON FUNCTION public.service_actor_for(uuid) FROM authenticated; END IF;
32
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN GRANT EXECUTE ON FUNCTION public.service_actor_for(uuid) TO service_role; END IF;
33
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN GRANT EXECUTE ON FUNCTION public.service_actor_for(uuid) TO fayz_integration; END IF;
34
+ END $$;
35
+
36
+ -- ── sync_runs: written by machines only ─────────────────────────────────────
37
+ -- A connector's sync log. Panels read it (SELECT policy for authenticated);
38
+ -- only a sync writes it, and the sync now runs in the service. The service
39
+ -- role gets exactly what service_role had here, scoped to this table.
40
+ DO $$
41
+ BEGIN
42
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') AND to_regclass('public.sync_runs') IS NOT NULL THEN
43
+ GRANT SELECT, INSERT, UPDATE ON public.sync_runs TO fayz_integration;
44
+ DROP POLICY IF EXISTS sync_runs_service ON public.sync_runs;
45
+ CREATE POLICY sync_runs_service ON public.sync_runs FOR ALL TO fayz_integration USING (true) WITH CHECK (true);
46
+ END IF;
47
+ END $$;
48
+
49
+ -- ── service_caller_belongs: "is this tenant yours?", asked as the caller ─────
50
+ -- The service verifies a caller's JWT, then asks the cluster, in the caller's
51
+ -- own role, whether the tenant they named is one of theirs. The answer comes
52
+ -- from user_tenant_ids(), the same set RLS uses. SECURITY INVOKER on purpose:
53
+ -- it says nothing a member could not already learn about themselves.
54
+ CREATE OR REPLACE FUNCTION public.service_caller_belongs(p_tenant uuid) RETURNS boolean
55
+ LANGUAGE sql STABLE SET search_path TO ''
56
+ AS $function$
57
+ SELECT p_tenant IN (SELECT public.user_tenant_ids());
58
+ $function$;
59
+
60
+ DO $$
61
+ BEGIN
62
+ REVOKE ALL ON FUNCTION public.service_caller_belongs(uuid) FROM PUBLIC;
63
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN REVOKE ALL ON FUNCTION public.service_caller_belongs(uuid) FROM anon; END IF;
64
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN GRANT EXECUTE ON FUNCTION public.service_caller_belongs(uuid) TO authenticated; END IF;
65
+ END $$;
@@ -0,0 +1,143 @@
1
+ -- ============================================================================
2
+ -- 213_the_service_reads_facts_and_rings_the_bell — two doors for the
3
+ -- Integration service, so a fact recorded here can reach a person through it.
4
+ --
5
+ -- ADR 0003: the database records what happened (event_log) and answers who may
6
+ -- see what; the service decides who is TOLD, and how. The first user is the
7
+ -- low-stock alert (plugin-inventory 031 records `inventory.stock_low`; the
8
+ -- service turns it into a bell line for whoever can read the stock, and is
9
+ -- where WhatsApp and e-mail join later).
10
+ --
11
+ -- event_log_for_service(keys, since, limit)
12
+ -- The facts of the given keys recorded since a moment, oldest first. The
13
+ -- service keeps its own receipt of what it handled and asks for a window
14
+ -- rather than a cursor: a transaction that committed late can carry a
15
+ -- created_at older than the newest fact already read, and a cursor would
16
+ -- step over it.
17
+ --
18
+ -- notifications_ring_for_service(tenant, permission, unit, type, title, body,
19
+ -- link, subject_type, subject_id, at)
20
+ -- One bell line per active member whose effective role holds `permission`
21
+ -- (or the `.manage` of its family) for `unit` — the same rule as
22
+ -- app._has_permission_raw, asked here for every member instead of re-made in
23
+ -- the service. The unique index of 130 on (user, subject_type, subject_id,
24
+ -- type) keeps one line per person per subject: a newer ring marks it unread
25
+ -- and moves it up; replaying the same ring (same `at`) changes nothing. The
26
+ -- bell is poked over realtime as the order bell does. Returns how many people
27
+ -- the line reached.
28
+ --
29
+ -- Granted to the service's role only (and service_role). Replay-safe.
30
+ -- ============================================================================
31
+
32
+ CREATE OR REPLACE FUNCTION public.event_log_for_service(
33
+ p_keys text[],
34
+ p_since timestamptz,
35
+ p_limit integer DEFAULT 200
36
+ )
37
+ RETURNS TABLE (
38
+ id uuid,
39
+ tenant_id uuid,
40
+ event_key text,
41
+ payload jsonb,
42
+ subject_type text,
43
+ subject_id text,
44
+ occurred_at timestamptz,
45
+ created_at timestamptz
46
+ )
47
+ LANGUAGE sql
48
+ STABLE
49
+ SECURITY DEFINER
50
+ SET search_path TO ''
51
+ AS $function$
52
+ SELECT e.id, e.tenant_id, e.event_key, e.payload, e.subject_type, e.subject_id,
53
+ e.occurred_at, e.created_at
54
+ FROM public.event_log e
55
+ WHERE e.event_key = ANY (p_keys)
56
+ AND e.created_at >= p_since
57
+ ORDER BY e.created_at, e.id
58
+ LIMIT least(greatest(coalesce(p_limit, 200), 1), 1000);
59
+ $function$;
60
+
61
+ CREATE OR REPLACE FUNCTION public.notifications_ring_for_service(
62
+ p_tenant uuid,
63
+ p_permission text,
64
+ p_unit uuid,
65
+ p_type text,
66
+ p_title text,
67
+ p_body text,
68
+ p_link text,
69
+ p_subject_type text,
70
+ p_subject_id uuid,
71
+ p_at timestamptz DEFAULT now()
72
+ )
73
+ RETURNS integer
74
+ LANGUAGE plpgsql
75
+ SECURITY DEFINER
76
+ SET search_path TO ''
77
+ AS $function$
78
+ DECLARE
79
+ v_reached integer;
80
+ BEGIN
81
+ IF p_tenant IS NULL OR coalesce(p_permission, '') = '' OR coalesce(p_type, '') = ''
82
+ OR coalesce(p_title, '') = '' OR p_subject_id IS NULL THEN
83
+ RAISE EXCEPTION 'notifications_ring_for_service: tenant, permission, type, title and subject are required'
84
+ USING ERRCODE = 'invalid_parameter_value';
85
+ END IF;
86
+
87
+ WITH recipients AS (
88
+ SELECT DISTINCT m.user_id
89
+ FROM app.memberships m
90
+ WHERE m.tenant_id = p_tenant AND m.active AND m.user_id IS NOT NULL
91
+ AND EXISTS (
92
+ SELECT 1 FROM app.role_permissions rp
93
+ WHERE rp.tenant_id = p_tenant
94
+ AND rp.role_id = app.effective_role_id(p_tenant, m.user_id, p_unit)
95
+ AND rp.permission IN (p_permission, regexp_replace(p_permission, '\.[^.]+$', '.manage')))
96
+ ), rung AS (
97
+ INSERT INTO public.notifications (tenant_id, user_id, title, body, type, link, subject_type, subject_id, created_at)
98
+ SELECT p_tenant, r.user_id, p_title, p_body, p_type, p_link, p_subject_type, p_subject_id, coalesce(p_at, now())
99
+ FROM recipients r
100
+ ON CONFLICT (user_id, subject_type, subject_id, type) WHERE subject_id IS NOT NULL
101
+ DO UPDATE SET read = false, created_at = EXCLUDED.created_at,
102
+ title = EXCLUDED.title, body = EXCLUDED.body, link = EXCLUDED.link
103
+ WHERE public.notifications.created_at < EXCLUDED.created_at
104
+ RETURNING 1
105
+ )
106
+ SELECT count(*)::integer INTO v_reached FROM rung;
107
+
108
+ IF v_reached > 0 AND to_regproc('realtime.send') IS NOT NULL THEN
109
+ BEGIN
110
+ PERFORM realtime.send(
111
+ jsonb_build_object('type', p_type, 'subjectId', p_subject_id),
112
+ 'notification',
113
+ -- Contract with `subscribeBell()` in packages/admin/src/lib/bell.ts.
114
+ 'avisos:' || p_tenant::text,
115
+ false);
116
+ EXCEPTION WHEN OTHERS THEN
117
+ RAISE WARNING 'notifications_ring_for_service: realtime: %', SQLERRM;
118
+ END;
119
+ END IF;
120
+
121
+ RETURN v_reached;
122
+ END;
123
+ $function$;
124
+
125
+ DO $$
126
+ DECLARE
127
+ f text;
128
+ BEGIN
129
+ FOREACH f IN ARRAY ARRAY[
130
+ 'public.event_log_for_service(text[], timestamptz, integer)',
131
+ 'public.notifications_ring_for_service(uuid, text, uuid, text, text, text, text, text, uuid, timestamptz)'
132
+ ] LOOP
133
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', f);
134
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN EXECUTE format('REVOKE ALL ON FUNCTION %s FROM anon', f); END IF;
135
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN EXECUTE format('REVOKE ALL ON FUNCTION %s FROM authenticated', f); END IF;
136
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', f); END IF;
137
+ -- The service's role, under its current name and the name it is moving to
138
+ -- (control_integration): granted to whichever exists, so this file is
139
+ -- right before and after the rename.
140
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fayz_integration') THEN EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO fayz_integration', f); END IF;
141
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'control_integration') THEN EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO control_integration', f); END IF;
142
+ END LOOP;
143
+ END $$;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "fayz": {
4
4
  "status": "beta"
5
5
  },
6
- "version": "0.24.2",
6
+ "version": "0.25.0",
7
7
  "description": "Fayz SDK database layer — Drizzle schema primitives, spine references, and migration helpers shared across plugins.",
8
8
  "type": "module",
9
9
  "sideEffects": false,