@fayz-ai/db 0.13.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 (187) hide show
  1. package/canonical-schema.json +106 -23
  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 +1 -1
  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 -46
  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 -90
  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
  85. package/migrations/050_authz_core.sql +0 -230
  86. package/migrations/051_authz_helpers.sql +0 -330
  87. package/migrations/052_authz_fanout.sql +0 -253
  88. package/migrations/053_authz_catalog_templates.sql +0 -402
  89. package/migrations/060_tenancy_seam.sql +0 -132
  90. package/migrations/061_create_tenant.sql +0 -125
  91. package/migrations/062_invitations.sql +0 -329
  92. package/migrations/063_legacy_tenancy_retired.sql +0 -568
  93. package/migrations/064_agent_guard_authz.sql +0 -118
  94. package/migrations/064b_supersede_unit_scope.sql +0 -205
  95. package/migrations/065_scaffold_sweep.sql +0 -139
  96. package/migrations/066_shadow_report.sql +0 -122
  97. package/migrations/070_permission_catalog.sql +0 -205
  98. package/migrations/071_v1_permission_map.sql +0 -266
  99. package/migrations/072_role_rpcs.sql +0 -126
  100. package/migrations/073_legacy_rbac_retire.sql +0 -471
  101. package/migrations/075_company_legal_entities.sql +0 -169
  102. package/migrations/076_tenant_settings.sql +0 -251
  103. package/migrations/080_people_roles.sql +0 -269
  104. package/migrations/081_people_identity.sql +0 -271
  105. package/migrations/082_people_membership_link.sql +0 -142
  106. package/migrations/083_people_data_migration.sql +0 -75
  107. package/migrations/084_people_registrations_tokens.sql +0 -282
  108. package/migrations/085_catalog_products.sql +0 -333
  109. package/migrations/086_catalog_pricing.sql +0 -194
  110. package/migrations/087_catalog_packages.sql +0 -134
  111. package/migrations/090_orders_venda.sql +0 -166
  112. package/migrations/091_orders_scaffold.sql +0 -92
  113. package/migrations/095_config_field_rules.sql +0 -261
  114. package/migrations/096_audit_append_only.sql +0 -256
  115. package/migrations/097_sequences_authorized.sql +0 -111
  116. package/migrations/098_holidays.sql +0 -80
  117. package/migrations/099_access_grants.sql +0 -293
  118. package/migrations/100_migration_schema.sql +0 -217
  119. package/migrations/101_migration_allowlist.sql +0 -202
  120. package/migrations/102_migration_rpcs.sql +0 -933
  121. package/migrations/103_migration_reconcile_ponr.sql +0 -132
  122. package/migrations/105_policies_to_authenticated.sql +0 -71
  123. package/migrations/106_function_search_path.sql +0 -55
  124. package/migrations/107_core_table_prefix.sql +0 -361
  125. package/migrations/108_authz_write_guards.sql +0 -149
  126. package/migrations/109_catalog_migration_promoted_columns.sql +0 -126
  127. package/migrations/110_people_kind_vocabulary.sql +0 -120
  128. package/migrations/111_migration_excluded_is_a_decision.sql +0 -259
  129. package/migrations/111_replacing_a_view_keeps_its_dependents.sql +0 -132
  130. package/migrations/112_money_precision_standard.sql +0 -132
  131. package/migrations/113_addresses_units_companies.sql +0 -114
  132. package/migrations/114_migrations_ledger_immutable.sql +0 -65
  133. package/migrations/115_registration_honours_field_rules.sql +0 -172
  134. package/migrations/116_field_rules_check_needs_a_tenant.sql +0 -72
  135. package/migrations/117_authz_reconciliation_schedule.sql +0 -82
  136. package/migrations/118_migration_catalog_writers.sql +0 -450
  137. package/migrations/119_migration_config_writers.sql +0 -193
  138. package/migrations/120_migration_facet_writer.sql +0 -139
  139. package/migrations/121_migration_financial_writers.sql +0 -294
  140. package/migrations/122_migration_inventory_writers.sql +0 -245
  141. package/migrations/123_migration_order_item_writer.sql +0 -144
  142. package/migrations/124_migration_ledger_writers.sql +0 -170
  143. package/migrations/125_migration_authz_people_writers.sql +0 -205
  144. package/migrations/126_owner_scoping_is_configuration.sql +0 -247
  145. package/migrations/127_scaffold_audit_hook.sql +0 -98
  146. package/migrations/128_shadow_tables_enforced.sql +0 -93
  147. package/migrations/129_order_kind_terminal_states.sql +0 -57
  148. package/migrations/130_every_login_is_a_person.sql +0 -196
  149. package/migrations/131_migration_invoice_split.sql +0 -127
  150. package/migrations/132_audit_prune_scheduled.sql +0 -28
  151. package/migrations/133_unit_is_a_session_property.sql +0 -155
  152. package/migrations/134_field_rules_on_read.sql +0 -341
  153. package/migrations/135_distribution_polarity.sql +0 -158
  154. package/migrations/136_unit_id_means_locality.sql +0 -358
  155. package/migrations/137_vendor_named_spine_objects.sql +0 -126
  156. package/migrations/138_grants_inherit_down_the_tree.sql +0 -178
  157. package/migrations/139_distributable_is_a_scaffold_option.sql +0 -109
  158. package/migrations/140_masking_is_a_scaffold_option.sql +0 -85
  159. package/migrations/141_renamed_objects_keep_a_compat_name.sql +0 -192
  160. package/migrations/142_a_person_has_addresses.sql +0 -212
  161. package/migrations/143_a_professional_has_a_rate.sql +0 -75
  162. package/migrations/144_a_product_can_be_sold_in_sizes.sql +0 -57
  163. package/migrations/145_the_app_says_which_tenant_it_is_in.sql +0 -123
  164. package/migrations/146_products_stock_has_one_writer.sql +0 -67
  165. package/migrations/147_every_archetype_has_a_kind_registry.sql +0 -388
  166. package/migrations/148_the_legacy_archetype_stops_being_writable.sql +0 -154
  167. package/migrations/149_the_event_log_gets_a_consumer.sql +0 -331
  168. package/migrations/150_the_app_is_configured_in_the_database.sql +0 -285
  169. package/migrations/151_stock_balance_is_an_archetype.sql +0 -144
  170. package/migrations/152_a_file_is_an_archetype.sql +0 -222
  171. package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +0 -199
  172. package/migrations/154_the_plan_is_a_ceiling.sql +0 -258
  173. package/migrations/155_a_tenant_can_leave.sql +0 -271
  174. package/migrations/156_the_service_door_has_a_server_side.sql +0 -335
  175. package/migrations/157_nobody_creates_objects_in_public.sql +0 -74
  176. package/migrations/158_the_registry_owns_the_status_vocabulary.sql +0 -153
  177. package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +0 -132
  178. package/migrations/160_the_vertical_decides_what_starts_on.sql +0 -246
  179. package/migrations/161_a_tenant_leaves_with_its_data.sql +0 -363
  180. package/migrations/162_every_name_has_one_creator_for_real.sql +0 -126
  181. package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +0 -184
  182. package/migrations/164_the_sequence_table_joins_the_register.sql +0 -26
  183. package/migrations/165_a_view_is_not_a_way_in.sql +0 -90
  184. package/migrations/166_the_platform_billing_leftovers_go.sql +0 -72
  185. package/migrations/167_a_view_gets_back_what_its_table_grants.sql +0 -112
  186. package/migrations/168_the_grant_census_stops_crying_wolf.sql +0 -33
  187. package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +0 -45
@@ -1,335 +0,0 @@
1
- -- ---------------------------------------------------------------------------
2
- -- 156_the_service_door_has_a_server_side.sql — the half of #278 that 153 could
3
- -- not carry: WHERE a service lives, and how it is called with no browser.
4
- --
5
- -- 153 built the decision layer — which services exist, who may call one, what
6
- -- happens when it is down, and what was called. What it deliberately did not
7
- -- build is the leg that leaves the cluster, because a migration that opened a
8
- -- socket would be the wrong kind of clever. That leg is an edge function
9
- -- (`functions/service-router`), and this file is what the function reads.
10
- --
11
- -- ── Why the endpoint is not a column on app.services ────────────────────────
12
- -- `services_read` grants SELECT on app.services to every authenticated user,
13
- -- and that is correct: a screen needs the manual fallback and the entitlement
14
- -- to render an honest refusal. An upstream URL is a different kind of fact —
15
- -- internal topology, sometimes carrying a path that names a vendor. It goes in
16
- -- its own table that `authenticated` cannot read at all, so widening the one
17
- -- does not widen the other.
18
- --
19
- -- ── Why the credential is a NAME and not a value ────────────────────────────
20
- -- app.service_endpoints stores `auth_env` — the name of the environment
21
- -- variable the router reads — never the secret. A secret in a table is a secret
22
- -- in every backup, every replica and every `pg_dump` somebody mails themselves.
23
- -- The cluster carries the credential in its function environment; the database
24
- -- carries only the instruction for finding it.
25
- --
26
- -- ── Why there is a second authorize ─────────────────────────────────────────
27
- -- `service_authorize` answers for auth.uid() in the current tenant, which is
28
- -- exactly right for a click. It is useless at 2am: the event consumer (#277)
29
- -- reacts to a fact with no session, no JWT and no user, and 153's function
30
- -- would answer `no_tenant` forever. `service_authorize_for` takes the tenant
31
- -- explicitly, is service_role-only, and skips the permission check because
32
- -- there is no person to have permissions — the entitlement check stays, because
33
- -- the tenant's contract is a property of the tenant, not of who is looking.
34
- -- ---------------------------------------------------------------------------
35
-
36
- -- ─────────────────────────────────────────────────────────────────────────
37
- -- §1 Where a service lives
38
- -- ─────────────────────────────────────────────────────────────────────────
39
-
40
- CREATE TABLE IF NOT EXISTS app.service_endpoints (
41
- service text PRIMARY KEY REFERENCES app.services(name) ON DELETE CASCADE,
42
- -- Absolute https URL. http is allowed only for loopback, so a dev machine
43
- -- works and a production typo does not silently downgrade the transport.
44
- url text NOT NULL CHECK (
45
- url ~ '^https://' OR url ~ '^http://(localhost|127\.0\.0\.1)(:[0-9]+)?(/|$)'
46
- ),
47
- -- The NAME of the env var holding the credential. Never the credential.
48
- auth_env text CHECK (auth_env IS NULL OR auth_env ~ '^[A-Z][A-Z0-9_]*$'),
49
- -- How the credential is presented: bearer (Authorization: Bearer …) or a
50
- -- header the vendor names.
51
- auth_style text NOT NULL DEFAULT 'bearer' CHECK (auth_style IN ('bearer', 'header', 'none')),
52
- auth_header text,
53
- created_at timestamptz NOT NULL DEFAULT now(),
54
- updated_at timestamptz NOT NULL DEFAULT now()
55
- );
56
-
57
- COMMENT ON TABLE app.service_endpoints IS
58
- 'Where each service actually lives, and the NAME of the env var holding the credential to reach it (156). Readable by service_role only: app.services is public to a signed-in user because a screen needs the fallback text, and internal topology is not the same fact.';
59
- COMMENT ON COLUMN app.service_endpoints.auth_env IS
60
- 'The name of an environment variable, never a secret. A secret in a table is a secret in every backup.';
61
-
62
- ALTER TABLE app.service_endpoints ENABLE ROW LEVEL SECURITY;
63
- -- No policy for `authenticated` on purpose: the table is service_role's.
64
- REVOKE ALL ON app.service_endpoints FROM anon, authenticated;
65
- GRANT ALL ON app.service_endpoints TO service_role;
66
-
67
- DROP TRIGGER IF EXISTS service_endpoints_updated_at ON app.service_endpoints;
68
- CREATE TRIGGER service_endpoints_updated_at BEFORE UPDATE ON app.service_endpoints
69
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
70
-
71
- CREATE OR REPLACE FUNCTION public.register_service_endpoint(
72
- p_service text,
73
- p_url text,
74
- p_auth_env text DEFAULT NULL,
75
- p_auth_style text DEFAULT 'bearer',
76
- p_auth_header text DEFAULT NULL
77
- ) RETURNS void
78
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
79
- AS $$
80
- BEGIN
81
- IF p_auth_style = 'header' AND coalesce(btrim(p_auth_header), '') = '' THEN
82
- RAISE EXCEPTION 'register_service_endpoint: auth_style=header needs auth_header'
83
- USING ERRCODE = '22023';
84
- END IF;
85
- INSERT INTO app.service_endpoints (service, url, auth_env, auth_style, auth_header)
86
- VALUES (p_service, p_url, p_auth_env, p_auth_style, p_auth_header)
87
- ON CONFLICT (service) DO UPDATE
88
- SET url = EXCLUDED.url, auth_env = EXCLUDED.auth_env,
89
- auth_style = EXCLUDED.auth_style, auth_header = EXCLUDED.auth_header;
90
- END $$;
91
-
92
- REVOKE ALL ON FUNCTION public.register_service_endpoint(text, text, text, text, text) FROM public, anon, authenticated;
93
- GRANT EXECUTE ON FUNCTION public.register_service_endpoint(text, text, text, text, text) TO service_role;
94
-
95
- -- What the router reads, in one call, after it has authorized. Returns NULL
96
- -- rather than raising when a service has no endpoint: "registered but not
97
- -- reachable yet" is a real and temporary state, and the router turns it into
98
- -- the same honest refusal as any other unreachable service.
99
- CREATE OR REPLACE FUNCTION public.service_endpoint(p_service text)
100
- RETURNS jsonb
101
- LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
102
- AS $$
103
- SELECT jsonb_build_object(
104
- 'service', e.service, 'url', e.url, 'auth_env', e.auth_env,
105
- 'auth_style', e.auth_style, 'auth_header', e.auth_header,
106
- 'timeout_ms', s.timeout_ms)
107
- FROM app.service_endpoints e
108
- JOIN app.services s ON s.name = e.service
109
- WHERE e.service = p_service AND s.is_active;
110
- $$;
111
-
112
- REVOKE ALL ON FUNCTION public.service_endpoint(text) FROM public, anon, authenticated;
113
- GRANT EXECUTE ON FUNCTION public.service_endpoint(text) TO service_role;
114
-
115
- -- ─────────────────────────────────────────────────────────────────────────
116
- -- §2 Authorizing with no person in the room
117
- -- ─────────────────────────────────────────────────────────────────────────
118
-
119
- -- 150's `tenant_plugin_active` reads app.current_tenant_id(), which is a JWT
120
- -- claim. A background reaction has no JWT, so it needs the same question asked
121
- -- of an explicit tenant. Same predicate, one argument more — not a second
122
- -- source of truth for what a tenant has.
123
- CREATE OR REPLACE FUNCTION public.tenant_plugin_active_for(p_tenant uuid, p_plugin text, p_facet text DEFAULT '')
124
- RETURNS boolean
125
- LANGUAGE sql STABLE SECURITY DEFINER SET search_path = ''
126
- AS $$
127
- SELECT EXISTS (
128
- SELECT 1 FROM app.tenant_plugins t
129
- WHERE t.tenant_id = p_tenant
130
- AND t.plugin_id = p_plugin
131
- AND t.facet = coalesce(p_facet, '')
132
- AND t.status = 'active'
133
- AND (t.expires_at IS NULL OR t.expires_at > now())
134
- )
135
- $$;
136
-
137
- COMMENT ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) IS
138
- 'tenant_plugin_active for an explicit tenant (156) — the same predicate, for a caller with no session. service_role only: a signed-in user asks about their own tenant and already has the other one.';
139
-
140
- REVOKE ALL ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) FROM public, anon, authenticated;
141
- GRANT EXECUTE ON FUNCTION public.tenant_plugin_active_for(uuid, text, text) TO service_role;
142
-
143
-
144
- CREATE OR REPLACE FUNCTION public.service_authorize_for(p_name text, p_tenant uuid)
145
- RETURNS jsonb
146
- LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
147
- AS $$
148
- DECLARE
149
- v_service app.services%ROWTYPE;
150
- BEGIN
151
- IF p_tenant IS NULL THEN
152
- RETURN jsonb_build_object('allowed', false, 'reason', 'no_tenant');
153
- END IF;
154
-
155
- SELECT * INTO v_service FROM app.services WHERE name = p_name;
156
- IF NOT FOUND OR NOT v_service.is_active THEN
157
- RETURN jsonb_build_object('allowed', false, 'reason', 'unknown_service');
158
- END IF;
159
-
160
- -- No permission check: there is no person. A background reaction acts for the
161
- -- tenant, not for a user, and inventing a user to check would be a lie the
162
- -- audit trail then repeats.
163
- IF v_service.entitlement IS NOT NULL
164
- AND NOT public.tenant_plugin_active_for(p_tenant, v_service.plugin, v_service.entitlement) THEN
165
- RETURN jsonb_build_object('allowed', false, 'reason', 'not_contracted',
166
- 'entitlement', v_service.entitlement,
167
- 'plugin', v_service.plugin,
168
- 'manual_fallback', v_service.manual_fallback);
169
- END IF;
170
-
171
- RETURN jsonb_build_object('allowed', true, 'service', v_service.name, 'tenant_id', p_tenant,
172
- 'timeout_ms', v_service.timeout_ms,
173
- 'manual_fallback', v_service.manual_fallback,
174
- 'actor', 'system');
175
- END $$;
176
-
177
- COMMENT ON FUNCTION public.service_authorize_for(text, uuid) IS
178
- 'service_authorize for a caller with no session (156): the event consumer reacting to a fact at 2am. Same entitlement rule, no permission rule — there is no person to have one. service_role only.';
179
-
180
- REVOKE ALL ON FUNCTION public.service_authorize_for(text, uuid) FROM public, anon, authenticated;
181
- GRANT EXECUTE ON FUNCTION public.service_authorize_for(text, uuid) TO service_role;
182
-
183
- -- The same for the history: a system call still has to be findable when
184
- -- somebody asks why the stock did not move.
185
- CREATE OR REPLACE FUNCTION public.service_call_record_for(
186
- p_service text, p_tenant uuid, p_status text,
187
- p_reason text DEFAULT NULL, p_duration_ms integer DEFAULT NULL
188
- ) RETURNS uuid
189
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
190
- AS $$
191
- DECLARE
192
- v_id uuid;
193
- BEGIN
194
- INSERT INTO app.service_calls (tenant_id, service, caller, status, reason, duration_ms)
195
- VALUES (p_tenant, p_service, NULL, p_status, p_reason, p_duration_ms)
196
- RETURNING id INTO v_id;
197
- RETURN v_id;
198
- END $$;
199
-
200
- COMMENT ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) IS
201
- 'Records a service call made with no session (156). caller is NULL — deliberately: the row says "the system did this", which is true, instead of naming a user who was asleep.';
202
-
203
- REVOKE ALL ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) FROM public, anon, authenticated;
204
- GRANT EXECUTE ON FUNCTION public.service_call_record_for(text, uuid, text, text, integer) TO service_role;
205
-
206
- -- ─────────────────────────────────────────────────────────────────────────
207
- -- §3 What a background reaction has queued
208
- --
209
- -- The consumer runs inside the database and cannot open a socket. So a handler
210
- -- that needs a service does not call it — it ASKS for it, and the same worker
211
- -- that drains the event log drains this. One more row, and the reason it is a
212
- -- row rather than a callback: a service that is down must not roll back the
213
- -- fact that was already recorded.
214
- -- ─────────────────────────────────────────────────────────────────────────
215
-
216
- CREATE TABLE IF NOT EXISTS app.service_requests (
217
- id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
218
- tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
219
- service text NOT NULL REFERENCES app.services(name) ON DELETE CASCADE,
220
- payload jsonb NOT NULL DEFAULT '{}'::jsonb,
221
- -- Where the answer goes: a function name the worker calls back with the
222
- -- result. NULL means fire-and-forget.
223
- callback text,
224
- origin text NOT NULL DEFAULT 'event' CHECK (origin IN ('event', 'schedule', 'manual')),
225
- origin_ref text,
226
- status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'sent', 'ok', 'failed', 'refused')),
227
- attempts integer NOT NULL DEFAULT 0,
228
- next_try_at timestamptz NOT NULL DEFAULT now(),
229
- last_error text,
230
- result jsonb,
231
- created_at timestamptz NOT NULL DEFAULT now(),
232
- updated_at timestamptz NOT NULL DEFAULT now()
233
- );
234
-
235
- COMMENT ON TABLE app.service_requests IS
236
- 'Service calls a background reaction asked for (156). A row rather than a callback, so a service being down cannot roll back the fact that caused it.';
237
-
238
- CREATE INDEX IF NOT EXISTS service_requests_due ON app.service_requests (next_try_at)
239
- WHERE status IN ('queued', 'failed');
240
-
241
- ALTER TABLE app.service_requests ENABLE ROW LEVEL SECURITY;
242
- DROP POLICY IF EXISTS service_requests_read ON app.service_requests;
243
- CREATE POLICY service_requests_read ON app.service_requests
244
- FOR SELECT TO authenticated
245
- USING (tenant_id IN (SELECT public.user_tenant_ids()));
246
- GRANT SELECT ON app.service_requests TO authenticated;
247
- GRANT ALL ON app.service_requests TO service_role;
248
-
249
- DROP TRIGGER IF EXISTS service_requests_updated_at ON app.service_requests;
250
- CREATE TRIGGER service_requests_updated_at BEFORE UPDATE ON app.service_requests
251
- FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at();
252
-
253
- -- What a handler calls. Deliberately cheap: it enqueues and returns, so a
254
- -- handler's transaction never waits on a network it cannot see.
255
- CREATE OR REPLACE FUNCTION public.service_request(
256
- p_tenant uuid, p_service text, p_payload jsonb DEFAULT '{}'::jsonb,
257
- p_callback text DEFAULT NULL, p_origin text DEFAULT 'event', p_origin_ref text DEFAULT NULL
258
- ) RETURNS uuid
259
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
260
- AS $$
261
- DECLARE
262
- v_id uuid;
263
- BEGIN
264
- INSERT INTO app.service_requests (tenant_id, service, payload, callback, origin, origin_ref)
265
- VALUES (p_tenant, p_service, coalesce(p_payload, '{}'::jsonb), p_callback, p_origin, p_origin_ref)
266
- RETURNING id INTO v_id;
267
- RETURN v_id;
268
- END $$;
269
-
270
- REVOKE ALL ON FUNCTION public.service_request(uuid, text, jsonb, text, text, text) FROM public, anon, authenticated;
271
- GRANT EXECUTE ON FUNCTION public.service_request(uuid, text, jsonb, text, text, text) TO service_role;
272
-
273
- -- What the worker drains. Same shape as consume_event_log: SKIP LOCKED so two
274
- -- workers do not fight, and a claim that marks before the call so a crash
275
- -- cannot produce a second identical request.
276
- CREATE OR REPLACE FUNCTION public.claim_service_requests(p_limit integer DEFAULT 20)
277
- RETURNS TABLE (id uuid, tenant_id uuid, service text, payload jsonb, callback text, attempts integer)
278
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
279
- AS $$
280
- BEGIN
281
- RETURN QUERY
282
- WITH claimed AS (
283
- SELECT r.id FROM app.service_requests r
284
- WHERE r.status IN ('queued', 'failed')
285
- AND r.next_try_at <= now()
286
- AND r.attempts < 5
287
- ORDER BY r.next_try_at
288
- LIMIT greatest(1, least(p_limit, 200))
289
- FOR UPDATE SKIP LOCKED
290
- )
291
- UPDATE app.service_requests r
292
- SET status = 'sent', attempts = r.attempts + 1
293
- FROM claimed c
294
- WHERE r.id = c.id
295
- RETURNING r.id, r.tenant_id, r.service, r.payload, r.callback, r.attempts;
296
- END $$;
297
-
298
- REVOKE ALL ON FUNCTION public.claim_service_requests(integer) FROM public, anon, authenticated;
299
- GRANT EXECUTE ON FUNCTION public.claim_service_requests(integer) TO service_role;
300
-
301
- CREATE OR REPLACE FUNCTION public.settle_service_request(
302
- p_id uuid, p_status text, p_result jsonb DEFAULT NULL, p_error text DEFAULT NULL
303
- ) RETURNS void
304
- LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
305
- AS $$
306
- BEGIN
307
- UPDATE app.service_requests
308
- SET status = p_status,
309
- result = coalesce(p_result, result),
310
- last_error = p_error,
311
- -- Same capped backoff as the event consumer, for the same reason: a
312
- -- service that is down for an hour must not be hammered for an hour.
313
- next_try_at = CASE WHEN p_status = 'failed'
314
- THEN now() + least(interval '1 hour',
315
- (power(2, attempts + 1)::text || ' seconds')::interval)
316
- ELSE next_try_at END
317
- WHERE id = p_id;
318
- END $$;
319
-
320
- REVOKE ALL ON FUNCTION public.settle_service_request(uuid, text, jsonb, text) FROM public, anon, authenticated;
321
- GRANT EXECUTE ON FUNCTION public.settle_service_request(uuid, text, jsonb, text) TO service_role;
322
-
323
- -- The one place to look when a background reaction did not happen.
324
- CREATE OR REPLACE VIEW public.v_service_queue
325
- WITH (security_invoker = true) AS
326
- SELECT r.tenant_id, r.service, r.status, r.origin, r.origin_ref,
327
- r.attempts, r.next_try_at, r.last_error, r.created_at
328
- FROM app.service_requests r
329
- WHERE r.status <> 'ok'
330
- ORDER BY r.created_at DESC;
331
-
332
- COMMENT ON VIEW public.v_service_queue IS
333
- 'Background service calls that have not succeeded (156) — the answer to "why did the reaction not happen?", in one place instead of per plugin.';
334
-
335
- GRANT SELECT ON public.v_service_queue TO authenticated, service_role;
@@ -1,74 +0,0 @@
1
- -- ---------------------------------------------------------------------------
2
- -- 157_nobody_creates_objects_in_public.sql — the other half of the definer rule
3
- -- (#283).
4
- --
5
- -- `SECURITY DEFINER` without a pinned `search_path` is the classic escalation
6
- -- shape: the function runs as its owner, and resolves unqualified names using
7
- -- the CALLER's path. An attacker who can create `public.now()` — or anything
8
- -- else the body calls unqualified — gets it executed as the owner.
9
- --
10
- -- Pinning the path on every definer function is the direct fix, and the plugins
11
- -- do that in their own migrations. This file removes the other precondition:
12
- -- the ability to create the shadowing object at all.
13
- --
14
- -- Postgres 15 already stopped granting CREATE on `public` to PUBLIC, so on a
15
- -- current cluster this is a no-op that documents an assumption. It is written
16
- -- down anyway because "it is the default" is exactly the kind of belief that
17
- -- silently stops being true — a restore from an older dump, a pool created
18
- -- before an upgrade, a `GRANT` somebody added for one migration and left.
19
- -- Cheap to assert, and asserting it is what makes the definer rule complete.
20
- --
21
- -- Nothing legitimate loses anything: migrations run as the owner, and the owner
22
- -- keeps CREATE. What loses is `authenticated` planting a function in the path
23
- -- of one that runs as somebody else.
24
- -- ---------------------------------------------------------------------------
25
-
26
- DO $$
27
- BEGIN
28
- IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN
29
- RETURN;
30
- END IF;
31
-
32
- REVOKE CREATE ON SCHEMA public FROM PUBLIC;
33
- -- Named explicitly as well: a role holding it directly is not covered by the
34
- -- revoke from PUBLIC, and that is the case somebody's one-off GRANT creates.
35
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
36
- REVOKE CREATE ON SCHEMA public FROM anon;
37
- END IF;
38
- IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
39
- REVOKE CREATE ON SCHEMA public FROM authenticated;
40
- END IF;
41
- END $$;
42
-
43
- COMMENT ON SCHEMA public IS
44
- 'The archetype schema. No role below the owner may CREATE here (157): a SECURITY DEFINER function resolving an unqualified name is only exploitable if somebody else can plant the object it resolves to.';
45
-
46
- -- ─────────────────────────────────────────────────────────────────────────
47
- -- The census: which definer functions still resolve names loosely.
48
- --
49
- -- The CI gate reads the repository, which is where a fix belongs. This reads
50
- -- the live catalog, which is where the truth is — a pool carries functions from
51
- -- versions the repository has moved past, and a plugin uninstalled from the
52
- -- code does not uninstall itself from the database.
53
- -- ─────────────────────────────────────────────────────────────────────────
54
-
55
- CREATE OR REPLACE VIEW public.v_definer_functions_unpinned
56
- WITH (security_invoker = true) AS
57
- SELECT n.nspname AS schema_name,
58
- p.proname AS function_name,
59
- pg_get_function_identity_arguments(p.oid) AS arguments,
60
- pg_get_userbyid(p.proowner) AS owner
61
- FROM pg_proc p
62
- JOIN pg_namespace n ON n.oid = p.pronamespace
63
- WHERE p.prosecdef
64
- AND n.nspname IN ('public', 'app')
65
- AND NOT EXISTS (
66
- SELECT 1 FROM unnest(coalesce(p.proconfig, ARRAY[]::text[])) AS cfg
67
- WHERE cfg LIKE 'search\_path=%'
68
- )
69
- ORDER BY n.nspname, p.proname;
70
-
71
- COMMENT ON VIEW public.v_definer_functions_unpinned IS
72
- 'SECURITY DEFINER functions in this database whose search_path is not pinned (157, #283). Zero is the target; a row here is a function that runs as its owner and resolves unqualified names as the caller.';
73
-
74
- GRANT SELECT ON public.v_definer_functions_unpinned TO service_role;
@@ -1,153 +0,0 @@
1
- -- ---------------------------------------------------------------------------
2
- -- 158_the_registry_owns_the_status_vocabulary.sql — the base takes back the one
3
- -- constraint that forced plugins to write a base table (#283, #274).
4
- --
5
- -- ── The thing that was wrong ────────────────────────────────────────────────
6
- -- A beauty-era migration put `orders_kind_status_valid` on public.orders: a
7
- -- CHECK whose body is a CASE over `kind`, listing the statuses each kind may
8
- -- have. Every time a plugin gained a status, the only way to say so was to DROP
9
- -- and re-ADD a constraint on an archetype the base owns — which plugin-crm did
10
- -- twice (009 for `archived` on a deal, 010 for the same on a quote), each time
11
- -- rewriting the branch for every OTHER plugin's kinds in the process.
12
- --
13
- -- That is not a plugin behaving badly. It is a base that left no other door.
14
- -- The rule "a plugin does not restructure a base table" is only fair once the
15
- -- base provides the mechanism, and since 090 it does: `order_kinds.statuses` is
16
- -- the per-kind vocabulary, and `trg_orders_kind_guard` already enforces it on
17
- -- every INSERT and UPDATE. The CHECK was a second opinion about the same fact —
18
- -- and the more expensive of the two, because widening it needed DDL on a table
19
- -- other plugins were writing at the time.
20
- --
21
- -- ── What this does, in order ───────────────────────────────────────────────
22
- -- 1. Reads the CHECK, if the pool has one, and folds any status it allowed into
23
- -- the registry. Nothing loosens and nothing tightens: a vocabulary the pool
24
- -- accepted yesterday is accepted today, now written where it can be read.
25
- -- 2. Drops the constraint.
26
- -- 3. Records the retirement, so the review gate can tell a historical ALTER
27
- -- from a new one instead of being handed a list of names to ignore.
28
- --
29
- -- Pools that never had the constraint — most of them — take only step 3.
30
- -- ---------------------------------------------------------------------------
31
-
32
- -- ─────────────────────────────────────────────────────────────────────────
33
- -- §1 Fold the CHECK's vocabulary into the registry
34
- -- ─────────────────────────────────────────────────────────────────────────
35
-
36
- DO $$
37
- DECLARE
38
- v_def text;
39
- v_branch text;
40
- v_kind text;
41
- v_statuses text[];
42
- v_added integer := 0;
43
- BEGIN
44
- IF to_regclass('public.orders') IS NULL OR to_regclass('public.order_kinds') IS NULL THEN
45
- RETURN;
46
- END IF;
47
-
48
- SELECT pg_get_constraintdef(c.oid) INTO v_def
49
- FROM pg_constraint c
50
- WHERE c.conname = 'orders_kind_status_valid'
51
- AND c.conrelid = 'public.orders'::regclass;
52
-
53
- IF v_def IS NULL THEN
54
- RAISE NOTICE '158: no orders_kind_status_valid on this pool — nothing to fold';
55
- RETURN;
56
- END IF;
57
-
58
- -- The CHECK's shape is fixed and known: WHEN 'kind' THEN status IN (…). Read
59
- -- it rather than hard-coding a list, because what matters is what THIS pool
60
- -- accepts — a pool that ran only some of the widening migrations has a
61
- -- narrower CHECK than the newest file describes, and copying the file's list
62
- -- would silently widen it.
63
- FOR v_branch IN
64
- SELECT m[1] || '|' || m[2]
65
- FROM regexp_matches(v_def, $re$WHEN '([a-z_]+)'::text THEN status = ANY \(ARRAY\[([^\]]*)\]\)$re$, 'g') AS m
66
- LOOP
67
- v_kind := split_part(v_branch, '|', 1);
68
- SELECT array_agg(DISTINCT btrim(btrim(s), ''''))
69
- INTO v_statuses
70
- FROM unnest(string_to_array(split_part(v_branch, '|', 2), ',')) AS s
71
- WHERE btrim(s) <> '';
72
-
73
- -- Fold, never replace: the union of what the CHECK allowed and what the
74
- -- registry already knew. Either source having a status is enough.
75
- UPDATE public.order_kinds k
76
- SET statuses = (
77
- SELECT array_agg(DISTINCT x)
78
- FROM unnest(coalesce(k.statuses, ARRAY[]::text[]) || v_statuses) AS x
79
- )
80
- WHERE k.kind = v_kind
81
- AND v_statuses IS NOT NULL
82
- AND NOT (coalesce(k.statuses, ARRAY[]::text[]) @> v_statuses);
83
- v_added := v_added + 1;
84
- END LOOP;
85
-
86
- RAISE NOTICE '158: folded % kind branch(es) from orders_kind_status_valid into order_kinds.statuses', v_added;
87
- END $$;
88
-
89
- -- ─────────────────────────────────────────────────────────────────────────
90
- -- §2 Drop it
91
- --
92
- -- Safe because 090's trigger has been enforcing the same rule from the registry
93
- -- since it landed: what goes away is a duplicate, not the enforcement.
94
- -- ─────────────────────────────────────────────────────────────────────────
95
-
96
- DO $$
97
- BEGIN
98
- IF to_regclass('public.orders') IS NULL THEN RETURN; END IF;
99
- IF EXISTS (
100
- SELECT 1 FROM pg_constraint
101
- WHERE conname = 'orders_kind_status_valid'
102
- AND conrelid = 'public.orders'::regclass
103
- ) THEN
104
- ALTER TABLE public.orders DROP CONSTRAINT orders_kind_status_valid;
105
- RAISE NOTICE '158: dropped orders_kind_status_valid — order_kinds.statuses is the vocabulary now';
106
- END IF;
107
- END $$;
108
-
109
- -- ─────────────────────────────────────────────────────────────────────────
110
- -- §3 Record the retirement
111
- --
112
- -- 148 gave the base a place to say which unprefixed names it owns, and the
113
- -- review gate reads it rather than carrying a list of its own. This is the same
114
- -- move one level down: a constraint the base has taken back, so a plugin's
115
- -- historical ALTER that names it can be told apart from a new one — by a rule
116
- -- the base states, not by an entry somebody added to the gate.
117
- -- ─────────────────────────────────────────────────────────────────────────
118
-
119
- CREATE TABLE IF NOT EXISTS app.retired_base_constraints (
120
- constraint_name text PRIMARY KEY,
121
- table_name text NOT NULL,
122
- retired_in text NOT NULL,
123
- reason text NOT NULL,
124
- created_at timestamptz NOT NULL DEFAULT now()
125
- );
126
-
127
- COMMENT ON TABLE app.retired_base_constraints IS
128
- 'Constraints on base tables that the base has withdrawn, and what replaced them (158). A plugin migration that alters one is history; a plugin migration that alters anything else is a violation. The review gate reads this instead of keeping its own list.';
129
-
130
- ALTER TABLE app.retired_base_constraints ENABLE ROW LEVEL SECURITY;
131
- DROP POLICY IF EXISTS retired_base_constraints_read ON app.retired_base_constraints;
132
- CREATE POLICY retired_base_constraints_read ON app.retired_base_constraints
133
- FOR SELECT TO authenticated, service_role USING (true);
134
- GRANT SELECT ON app.retired_base_constraints TO authenticated, service_role;
135
-
136
- INSERT INTO app.retired_base_constraints (constraint_name, table_name, retired_in, reason) VALUES
137
- ('orders_kind_status_valid', 'orders', '158',
138
- 'A CASE-over-kind CHECK duplicating what order_kinds.statuses says and trg_orders_kind_guard has enforced since 090. Widening it needed DDL on a base table, which is why two plugin migrations hold an ALTER TABLE public.orders. The registry is the vocabulary now: a plugin widens its own kind by updating its own row.')
139
- ON CONFLICT (constraint_name) DO UPDATE
140
- SET table_name = EXCLUDED.table_name, retired_in = EXCLUDED.retired_in, reason = EXCLUDED.reason;
141
-
142
- -- The census, for a pool: has anything put it back?
143
- CREATE OR REPLACE VIEW public.v_retired_constraints_resurrected
144
- WITH (security_invoker = true) AS
145
- SELECT r.constraint_name, r.table_name, r.retired_in, r.reason
146
- FROM app.retired_base_constraints r
147
- JOIN pg_constraint c ON c.conname = r.constraint_name
148
- JOIN pg_class t ON t.oid = c.conrelid AND t.relname = r.table_name;
149
-
150
- COMMENT ON VIEW public.v_retired_constraints_resurrected IS
151
- 'Retired base constraints that exist again in this database (158). Zero is the expected reading; a row means a migration re-created something the base withdrew.';
152
-
153
- GRANT SELECT ON public.v_retired_constraints_resurrected TO authenticated, service_role;