@fayz-ai/db 0.11.0 → 0.13.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.
- package/canonical-schema.json +49 -0
- package/migrations/010_migration_ledger.sql +11 -1
- package/migrations/037_sync_tick_one_at_a_time.sql +256 -0
- package/migrations/038_onboarding_responses.sql +103 -0
- package/migrations/039_unit_tree.sql +270 -0
- package/migrations/040_resource_grants.sql +474 -0
- package/migrations/041_scoped_columns.sql +192 -0
- package/migrations/042_unit_scope_policies.sql +145 -0
- package/migrations/043_view_invoker.sql +90 -0
- package/migrations/044_unit_member_facts.sql +47 -0
- package/migrations/045_unit_entry.sql +236 -0
- package/migrations/046_membership_visible_to_members.sql +85 -0
- package/migrations/047_tasks.sql +266 -0
- package/migrations/048_every_login_is_a_person.sql +190 -0
- package/migrations/049_bookable_people.sql +126 -0
- package/migrations/050_authz_core.sql +230 -0
- package/migrations/051_authz_helpers.sql +330 -0
- package/migrations/052_authz_fanout.sql +253 -0
- package/migrations/053_authz_catalog_templates.sql +402 -0
- package/migrations/060_tenancy_seam.sql +132 -0
- package/migrations/061_create_tenant.sql +125 -0
- package/migrations/062_invitations.sql +329 -0
- package/migrations/063_legacy_tenancy_retired.sql +568 -0
- package/migrations/064_agent_guard_authz.sql +118 -0
- package/migrations/064b_supersede_unit_scope.sql +205 -0
- package/migrations/065_scaffold_sweep.sql +139 -0
- package/migrations/066_shadow_report.sql +122 -0
- package/migrations/070_permission_catalog.sql +205 -0
- package/migrations/071_v1_permission_map.sql +266 -0
- package/migrations/072_role_rpcs.sql +126 -0
- package/migrations/073_legacy_rbac_retire.sql +471 -0
- package/migrations/075_company_legal_entities.sql +169 -0
- package/migrations/076_tenant_settings.sql +251 -0
- package/migrations/080_people_roles.sql +269 -0
- package/migrations/081_people_identity.sql +271 -0
- package/migrations/082_people_membership_link.sql +142 -0
- package/migrations/083_people_data_migration.sql +75 -0
- package/migrations/084_people_registrations_tokens.sql +282 -0
- package/migrations/085_catalog_products.sql +333 -0
- package/migrations/086_catalog_pricing.sql +194 -0
- package/migrations/087_catalog_packages.sql +134 -0
- package/migrations/090_orders_venda.sql +166 -0
- package/migrations/091_orders_scaffold.sql +92 -0
- package/migrations/095_config_field_rules.sql +261 -0
- package/migrations/096_audit_append_only.sql +256 -0
- package/migrations/097_sequences_authorized.sql +111 -0
- package/migrations/098_holidays.sql +80 -0
- package/migrations/099_access_grants.sql +293 -0
- package/migrations/100_migration_schema.sql +217 -0
- package/migrations/101_migration_allowlist.sql +202 -0
- package/migrations/102_migration_rpcs.sql +933 -0
- package/migrations/103_migration_reconcile_ponr.sql +132 -0
- package/migrations/105_policies_to_authenticated.sql +71 -0
- package/migrations/106_function_search_path.sql +55 -0
- package/migrations/107_core_table_prefix.sql +361 -0
- package/migrations/108_authz_write_guards.sql +149 -0
- package/migrations/109_catalog_migration_promoted_columns.sql +126 -0
- package/migrations/110_people_kind_vocabulary.sql +120 -0
- package/migrations/111_migration_excluded_is_a_decision.sql +259 -0
- package/migrations/111_replacing_a_view_keeps_its_dependents.sql +132 -0
- package/migrations/112_money_precision_standard.sql +132 -0
- package/migrations/113_addresses_units_companies.sql +114 -0
- package/migrations/114_migrations_ledger_immutable.sql +65 -0
- package/migrations/115_registration_honours_field_rules.sql +172 -0
- package/migrations/116_field_rules_check_needs_a_tenant.sql +72 -0
- package/migrations/117_authz_reconciliation_schedule.sql +82 -0
- package/migrations/118_migration_catalog_writers.sql +450 -0
- package/migrations/119_migration_config_writers.sql +193 -0
- package/migrations/120_migration_facet_writer.sql +139 -0
- package/migrations/121_migration_financial_writers.sql +294 -0
- package/migrations/122_migration_inventory_writers.sql +245 -0
- package/migrations/123_migration_order_item_writer.sql +144 -0
- package/migrations/124_migration_ledger_writers.sql +170 -0
- package/migrations/125_migration_authz_people_writers.sql +205 -0
- package/migrations/126_owner_scoping_is_configuration.sql +247 -0
- package/migrations/127_scaffold_audit_hook.sql +98 -0
- package/migrations/128_shadow_tables_enforced.sql +93 -0
- package/migrations/129_order_kind_terminal_states.sql +57 -0
- package/migrations/130_every_login_is_a_person.sql +196 -0
- package/migrations/131_migration_invoice_split.sql +127 -0
- package/migrations/132_audit_prune_scheduled.sql +28 -0
- package/migrations/133_unit_is_a_session_property.sql +155 -0
- package/migrations/134_field_rules_on_read.sql +341 -0
- package/migrations/135_distribution_polarity.sql +158 -0
- package/migrations/136_unit_id_means_locality.sql +358 -0
- package/migrations/137_vendor_named_spine_objects.sql +126 -0
- package/migrations/138_grants_inherit_down_the_tree.sql +178 -0
- package/migrations/139_distributable_is_a_scaffold_option.sql +109 -0
- package/migrations/140_masking_is_a_scaffold_option.sql +85 -0
- package/migrations/141_renamed_objects_keep_a_compat_name.sql +192 -0
- package/migrations/142_a_person_has_addresses.sql +212 -0
- package/migrations/143_a_professional_has_a_rate.sql +75 -0
- package/migrations/144_a_product_can_be_sold_in_sizes.sql +57 -0
- package/migrations/145_the_app_says_which_tenant_it_is_in.sql +123 -0
- package/migrations/146_products_stock_has_one_writer.sql +67 -0
- package/migrations/147_every_archetype_has_a_kind_registry.sql +388 -0
- package/migrations/148_the_legacy_archetype_stops_being_writable.sql +154 -0
- package/migrations/149_the_event_log_gets_a_consumer.sql +331 -0
- package/migrations/150_the_app_is_configured_in_the_database.sql +285 -0
- package/migrations/151_stock_balance_is_an_archetype.sql +144 -0
- package/migrations/152_a_file_is_an_archetype.sql +222 -0
- package/migrations/153_a_plugin_calls_a_service_through_one_door.sql +199 -0
- package/migrations/154_the_plan_is_a_ceiling.sql +258 -0
- package/migrations/155_a_tenant_can_leave.sql +271 -0
- package/migrations/156_the_service_door_has_a_server_side.sql +335 -0
- package/migrations/157_nobody_creates_objects_in_public.sql +74 -0
- package/migrations/158_the_registry_owns_the_status_vocabulary.sql +153 -0
- package/migrations/159_the_plan_catalogue_gets_its_first_rows.sql +132 -0
- package/migrations/160_the_vertical_decides_what_starts_on.sql +246 -0
- package/migrations/161_a_tenant_leaves_with_its_data.sql +363 -0
- package/migrations/162_every_name_has_one_creator_for_real.sql +126 -0
- package/migrations/163_a_species_nobody_declared_goes_to_quarantine.sql +184 -0
- package/migrations/164_the_sequence_table_joins_the_register.sql +26 -0
- package/migrations/165_a_view_is_not_a_way_in.sql +90 -0
- package/migrations/166_the_platform_billing_leftovers_go.sql +72 -0
- package/migrations/167_a_view_gets_back_what_its_table_grants.sql +112 -0
- package/migrations/168_the_grant_census_stops_crying_wolf.sql +33 -0
- package/migrations/169_the_census_asks_postgres_instead_of_guessing.sql +45 -0
- package/package.json +6 -5
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 163_a_species_nobody_declared_goes_to_quarantine.sql — the half of #274 that
|
|
3
|
+
-- 147 could only refuse (#274, ADR 0017).
|
|
4
|
+
--
|
|
5
|
+
-- 147's rule is that a V1 row whose species nobody declared goes to quarantine
|
|
6
|
+
-- and NEVER weakens the constraint. It kept the second half absolutely: a value
|
|
7
|
+
-- outside the identifier format raises, and the FK it is about to create is not
|
|
8
|
+
-- softened to accommodate it. What it could not do is the first half — moving
|
|
9
|
+
-- the row — because at that point in the file there is nowhere to move it to.
|
|
10
|
+
--
|
|
11
|
+
-- This is that half, and it is deliberately non-destructive. Three things
|
|
12
|
+
-- happen to a row whose species has no owner:
|
|
13
|
+
--
|
|
14
|
+
-- 1. the row is RECORDED, whole, in `migration.kind_quarantine` — so the
|
|
15
|
+
-- original value survives even after the column is repaired;
|
|
16
|
+
-- 2. the column is repointed at `unclassified`, a species the base declares
|
|
17
|
+
-- for exactly this, so the foreign key holds;
|
|
18
|
+
-- 3. nothing is deleted. Not the row, not the value, not the history.
|
|
19
|
+
--
|
|
20
|
+
-- ── Why `unclassified` and not NULL ────────────────────────────────────────
|
|
21
|
+
-- `kind` is NOT NULL on every archetype, and it should be: a Compromisso with
|
|
22
|
+
-- no species is a row nothing can render. A declared species meaning "nobody
|
|
23
|
+
-- has said what this is" is honest, queryable, and impossible to mistake for a
|
|
24
|
+
-- real classification — which is what a silently-invented species would be.
|
|
25
|
+
--
|
|
26
|
+
-- ── Why this cannot fix a pool that 147 already refused ────────────────────
|
|
27
|
+
-- 147 runs first and raises before this file exists. That ordering is correct:
|
|
28
|
+
-- refusing is safer than repairing something nobody has looked at. The runbook
|
|
29
|
+
-- for a pool in that state is to apply up to 146, run the body of §2 by hand,
|
|
30
|
+
-- then continue — which is why §2 is a FUNCTION rather than a loose DO block.
|
|
31
|
+
-- ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
34
|
+
-- §1 Where an undeclared species goes
|
|
35
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
CREATE SCHEMA IF NOT EXISTS migration;
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS migration.kind_quarantine (
|
|
40
|
+
id bigserial PRIMARY KEY,
|
|
41
|
+
tenant_id uuid,
|
|
42
|
+
archetype text NOT NULL,
|
|
43
|
+
table_name text NOT NULL,
|
|
44
|
+
column_name text NOT NULL,
|
|
45
|
+
row_id uuid,
|
|
46
|
+
-- The value as it was, before the column was repaired. This is the whole
|
|
47
|
+
-- point: a species is a business word, and the word is the evidence.
|
|
48
|
+
original_kind text NOT NULL,
|
|
49
|
+
payload jsonb,
|
|
50
|
+
reason text NOT NULL,
|
|
51
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
52
|
+
resolved_at timestamptz,
|
|
53
|
+
resolution text
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
COMMENT ON TABLE migration.kind_quarantine IS
|
|
57
|
+
'Rows whose species no plugin declared (163, #274). The original value is kept here and the live column is repointed at `unclassified`, so the foreign key holds without anything being deleted or any constraint being weakened. A human registers the species and re-points the rows.';
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS kind_quarantine_open_idx
|
|
60
|
+
ON migration.kind_quarantine (archetype, original_kind) WHERE resolved_at IS NULL;
|
|
61
|
+
|
|
62
|
+
-- The migration schema's contract: RLS on every table, no policies, so
|
|
63
|
+
-- PostgREST is deny-all and only the service role (which bypasses RLS) reaches
|
|
64
|
+
-- it. Asserted by 090_migration_ledger/001.
|
|
65
|
+
ALTER TABLE migration.kind_quarantine ENABLE ROW LEVEL SECURITY;
|
|
66
|
+
REVOKE ALL ON migration.kind_quarantine FROM public, anon, authenticated;
|
|
67
|
+
GRANT ALL ON migration.kind_quarantine TO service_role;
|
|
68
|
+
GRANT USAGE, SELECT ON SEQUENCE migration.kind_quarantine_id_seq TO service_role;
|
|
69
|
+
|
|
70
|
+
-- The species that means "nobody has said what this is". Declared on every
|
|
71
|
+
-- archetype so the repair has somewhere to point, whatever the table.
|
|
72
|
+
DO $$
|
|
73
|
+
DECLARE
|
|
74
|
+
v_archetype text;
|
|
75
|
+
BEGIN
|
|
76
|
+
IF to_regprocedure('public.register_kind(text, text, text, text[], jsonb, text)') IS NULL THEN
|
|
77
|
+
RETURN;
|
|
78
|
+
END IF;
|
|
79
|
+
FOREACH v_archetype IN ARRAY ARRAY['person', 'person_role', 'category', 'appointment', 'schedule', 'order', 'document']
|
|
80
|
+
LOOP
|
|
81
|
+
BEGIN
|
|
82
|
+
PERFORM public.register_kind(
|
|
83
|
+
v_archetype, 'unclassified', 'base', NULL, NULL,
|
|
84
|
+
'Nobody has declared what this row is. Set by 163 when a V1 value had no owner — the original is in migration.kind_quarantine, and this is not a classification, it is the absence of one.');
|
|
85
|
+
EXCEPTION WHEN OTHERS THEN
|
|
86
|
+
RAISE NOTICE '163: could not declare unclassified for % (%)', v_archetype, SQLERRM;
|
|
87
|
+
END;
|
|
88
|
+
END LOOP;
|
|
89
|
+
END $$;
|
|
90
|
+
|
|
91
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
92
|
+
-- §2 The sweep
|
|
93
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
CREATE OR REPLACE FUNCTION public.quarantine_unregistered_kinds(p_dry_run boolean DEFAULT true)
|
|
96
|
+
RETURNS TABLE (archetype text, table_name text, original_kind text, rows_affected bigint, action text)
|
|
97
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
98
|
+
AS $$
|
|
99
|
+
DECLARE
|
|
100
|
+
v_pair record;
|
|
101
|
+
v_bad record;
|
|
102
|
+
v_registry text;
|
|
103
|
+
v_n bigint;
|
|
104
|
+
BEGIN
|
|
105
|
+
FOR v_pair IN SELECT * FROM (VALUES
|
|
106
|
+
('person', 'people', 'kind'),
|
|
107
|
+
('person_role', 'person_roles', 'role'),
|
|
108
|
+
('category', 'categories', 'kind'),
|
|
109
|
+
('appointment', 'appointments', 'kind'),
|
|
110
|
+
('schedule', 'schedules', 'kind'),
|
|
111
|
+
('order', 'orders', 'kind'),
|
|
112
|
+
('document', 'documents', 'kind')
|
|
113
|
+
) AS p(arch, tbl, col)
|
|
114
|
+
LOOP
|
|
115
|
+
IF to_regclass('public.' || quote_ident(v_pair.tbl)) IS NULL THEN CONTINUE; END IF;
|
|
116
|
+
v_registry := public.kind_registry_table(v_pair.arch);
|
|
117
|
+
IF v_registry IS NULL OR to_regclass('public.' || quote_ident(v_registry)) IS NULL THEN CONTINUE; END IF;
|
|
118
|
+
|
|
119
|
+
-- Every distinct value in the data that the registry does not know.
|
|
120
|
+
FOR v_bad IN EXECUTE format(
|
|
121
|
+
'SELECT t.%I AS k, count(*)::bigint AS n FROM public.%I t
|
|
122
|
+
WHERE t.%I IS NOT NULL
|
|
123
|
+
AND NOT EXISTS (SELECT 1 FROM public.%I r WHERE r.kind = t.%I)
|
|
124
|
+
GROUP BY t.%I',
|
|
125
|
+
v_pair.col, v_pair.tbl, v_pair.col, v_registry, v_pair.col, v_pair.col)
|
|
126
|
+
LOOP
|
|
127
|
+
archetype := v_pair.arch;
|
|
128
|
+
table_name := v_pair.tbl;
|
|
129
|
+
original_kind := v_bad.k;
|
|
130
|
+
rows_affected := v_bad.n;
|
|
131
|
+
|
|
132
|
+
IF p_dry_run THEN
|
|
133
|
+
action := 'would quarantine';
|
|
134
|
+
RETURN NEXT;
|
|
135
|
+
CONTINUE;
|
|
136
|
+
END IF;
|
|
137
|
+
|
|
138
|
+
-- Record first, repair second. In that order, because a crash between
|
|
139
|
+
-- the two must lose the repair, never the evidence.
|
|
140
|
+
EXECUTE format(
|
|
141
|
+
'INSERT INTO migration.kind_quarantine (tenant_id, archetype, table_name, column_name, row_id, original_kind, payload, reason)
|
|
142
|
+
SELECT t.tenant_id, %L, %L, %L, t.id, t.%I, to_jsonb(t),
|
|
143
|
+
''No plugin declared this species. Repointed at unclassified by 163 so the FK holds - the word is kept here.''
|
|
144
|
+
FROM public.%I t WHERE t.%I = %L',
|
|
145
|
+
v_pair.arch, v_pair.tbl, v_pair.col, v_pair.col, v_pair.tbl, v_pair.col, v_bad.k);
|
|
146
|
+
|
|
147
|
+
EXECUTE format('UPDATE public.%I SET %I = ''unclassified'' WHERE %I = %L',
|
|
148
|
+
v_pair.tbl, v_pair.col, v_pair.col, v_bad.k);
|
|
149
|
+
GET DIAGNOSTICS v_n = ROW_COUNT;
|
|
150
|
+
rows_affected := v_n;
|
|
151
|
+
action := 'quarantined';
|
|
152
|
+
RETURN NEXT;
|
|
153
|
+
END LOOP;
|
|
154
|
+
END LOOP;
|
|
155
|
+
END $$;
|
|
156
|
+
|
|
157
|
+
COMMENT ON FUNCTION public.quarantine_unregistered_kinds(boolean) IS
|
|
158
|
+
'Moves rows whose species nobody declared out of the way WITHOUT deleting anything (163, #274): the original value goes to migration.kind_quarantine and the column is repointed at `unclassified`. Dry run by default — an irreversible-looking sweep should have to be asked for twice.';
|
|
159
|
+
|
|
160
|
+
REVOKE ALL ON FUNCTION public.quarantine_unregistered_kinds(boolean) FROM public, anon, authenticated;
|
|
161
|
+
GRANT EXECUTE ON FUNCTION public.quarantine_unregistered_kinds(boolean) TO service_role;
|
|
162
|
+
|
|
163
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
164
|
+
-- §3 The census, from the database rather than from CI
|
|
165
|
+
--
|
|
166
|
+
-- `scripts/check-kind-registry.mjs` reads the repository and answers "does the
|
|
167
|
+
-- code declare what the code writes". This answers the other question: does
|
|
168
|
+
-- anything in THIS pool still have no owner. 147 adopted every value it found
|
|
169
|
+
-- with owner `unattributed`, and that is the backlog.
|
|
170
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
CREATE OR REPLACE VIEW public.v_species_without_owner
|
|
173
|
+
WITH (security_invoker = true) AS
|
|
174
|
+
SELECT 'person'::text AS archetype, k.kind, k.plugin, k.status FROM public.person_kinds k WHERE k.plugin = 'unattributed'
|
|
175
|
+
UNION ALL SELECT 'person_role', k.kind, k.plugin, k.status FROM public.person_role_kinds k WHERE k.plugin = 'unattributed'
|
|
176
|
+
UNION ALL SELECT 'category', k.kind, k.plugin, k.status FROM public.category_kinds k WHERE k.plugin = 'unattributed'
|
|
177
|
+
UNION ALL SELECT 'appointment', k.kind, k.plugin, k.status FROM public.appointment_kinds k WHERE k.plugin = 'unattributed'
|
|
178
|
+
UNION ALL SELECT 'schedule', k.kind, k.plugin, k.status FROM public.schedule_kinds k WHERE k.plugin = 'unattributed'
|
|
179
|
+
UNION ALL SELECT 'order', k.kind, k.plugin, k.status FROM public.order_kinds k WHERE k.plugin = 'unattributed';
|
|
180
|
+
|
|
181
|
+
COMMENT ON VIEW public.v_species_without_owner IS
|
|
182
|
+
'Species present in this pool that 147 adopted with no owner (163, #274). Each row is a plugin that has not declared something it writes — the census that turns adoption into work rather than into a permanent state.';
|
|
183
|
+
|
|
184
|
+
GRANT SELECT ON public.v_species_without_owner TO authenticated, service_role;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 164_the_sequence_table_joins_the_register.sql — one more name whose creator
|
|
3
|
+
-- changed (#285, #283).
|
|
4
|
+
--
|
|
5
|
+
-- `public.sequences` — per-tenant document numbering — has been base-owned since
|
|
6
|
+
-- spine 097. plugin-financial created it in its own 004 and stopped, and the
|
|
7
|
+
-- assertion that replaced the CREATE is plugin-financial 037.
|
|
8
|
+
--
|
|
9
|
+
-- ── Why this is a new file and not a line added to 162 ─────────────────────
|
|
10
|
+
-- Because 162 had already been applied to a pool, and the ledger treats a
|
|
11
|
+
-- changed checksum on an applied file as a hard stop. I learned that here the
|
|
12
|
+
-- hard way, one migration after watching the same rule stop #285's edit to
|
|
13
|
+
-- plugin-financial 004 — which is the better argument for the rule than any
|
|
14
|
+
-- comment: it catches the person who wrote it, on the same afternoon, and it
|
|
15
|
+
-- catches them before a pool is left in a state nobody can describe.
|
|
16
|
+
--
|
|
17
|
+
-- The register is append-only in practice for exactly this reason. A name that
|
|
18
|
+
-- changes hands gets a new file, every time.
|
|
19
|
+
-- ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
INSERT INTO app.adopted_table_names (table_name, former_creator, new_owner, adopted_in, disposition, note) VALUES
|
|
22
|
+
('sequences', 'financial', 'base', '097', 'adopted',
|
|
23
|
+
'Per-tenant document numbering, base-owned since spine 097. plugin-financial created it in 004 and stopped — the assertion that replaced the CREATE is financial 037, a NEW file, because 004 had already run on every pool.')
|
|
24
|
+
ON CONFLICT (table_name) DO UPDATE
|
|
25
|
+
SET former_creator = EXCLUDED.former_creator, new_owner = EXCLUDED.new_owner,
|
|
26
|
+
adopted_in = EXCLUDED.adopted_in, disposition = EXCLUDED.disposition, note = EXCLUDED.note;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 165_a_view_is_not_a_way_in.sql — every view in `public` stops being born
|
|
3
|
+
-- writable by anon.
|
|
4
|
+
--
|
|
5
|
+
-- ── The hole, measured ─────────────────────────────────────────────────────
|
|
6
|
+
-- `ALTER DEFAULT PRIVILEGES` in this schema grants anon, authenticated and
|
|
7
|
+
-- service_role `arwdDxtm` on every table AND VIEW that postgres creates in
|
|
8
|
+
-- `public` — and migrations run as postgres. So a view is BORN with insert,
|
|
9
|
+
-- update and delete for an unauthenticated caller, whatever the table beneath
|
|
10
|
+
-- it grants, and a hand-written `GRANT SELECT` after the fact adds nothing
|
|
11
|
+
-- because the default already gave more.
|
|
12
|
+
--
|
|
13
|
+
-- On the ecommerce cluster, read the day this was written: **85 of 94 views in
|
|
14
|
+
-- `public` accept INSERT from `anon`.** Eleven tenants share that database.
|
|
15
|
+
--
|
|
16
|
+
-- Why it is not always exploitable is not a reason to leave it: a simple view
|
|
17
|
+
-- is auto-updatable, so a write through it reaches the table. Where the view
|
|
18
|
+
-- carries `security_invoker` the caller's own RLS still applies and anon is
|
|
19
|
+
-- usually stopped there — but a view WITHOUT invoker runs as its owner and RLS
|
|
20
|
+
-- does not apply at all. That combination is a straight bypass, and the only
|
|
21
|
+
-- thing standing between the two cases is which flag somebody remembered.
|
|
22
|
+
--
|
|
23
|
+
-- ── What this does ─────────────────────────────────────────────────────────
|
|
24
|
+
-- Revokes INSERT, UPDATE, DELETE and TRUNCATE from anon and authenticated on
|
|
25
|
+
-- every view in `public`. SELECT is left exactly as it was: reading is what a
|
|
26
|
+
-- view is for, and this file is not the place to relitigate who may read what.
|
|
27
|
+
-- service_role keeps everything — it bypasses RLS by design and is the runner.
|
|
28
|
+
--
|
|
29
|
+
-- A view that genuinely needs to be written through (an INSTEAD OF trigger, a
|
|
30
|
+
-- deliberate write surface) grants that back in its own migration, where the
|
|
31
|
+
-- decision is visible. None exists today.
|
|
32
|
+
--
|
|
33
|
+
-- Found by a parallel session reviewing 148's compat alias, which is the
|
|
34
|
+
-- narrow case; the census showed it was every view.
|
|
35
|
+
-- ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
DO $$
|
|
38
|
+
DECLARE
|
|
39
|
+
v_view record;
|
|
40
|
+
v_fixed integer := 0;
|
|
41
|
+
BEGIN
|
|
42
|
+
FOR v_view IN
|
|
43
|
+
SELECT c.relname
|
|
44
|
+
FROM pg_class c
|
|
45
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
46
|
+
WHERE n.nspname = 'public'
|
|
47
|
+
AND c.relkind IN ('v', 'm')
|
|
48
|
+
ORDER BY c.relname
|
|
49
|
+
LOOP
|
|
50
|
+
IF has_table_privilege('anon', format('public.%I', v_view.relname), 'INSERT')
|
|
51
|
+
OR has_table_privilege('anon', format('public.%I', v_view.relname), 'UPDATE')
|
|
52
|
+
OR has_table_privilege('anon', format('public.%I', v_view.relname), 'DELETE')
|
|
53
|
+
OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'INSERT')
|
|
54
|
+
OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'UPDATE')
|
|
55
|
+
OR has_table_privilege('authenticated', format('public.%I', v_view.relname), 'DELETE') THEN
|
|
56
|
+
EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.%I FROM anon, authenticated', v_view.relname);
|
|
57
|
+
v_fixed := v_fixed + 1;
|
|
58
|
+
END IF;
|
|
59
|
+
END LOOP;
|
|
60
|
+
RAISE NOTICE '165: closed the write grant on % view(s)', v_fixed;
|
|
61
|
+
END $$;
|
|
62
|
+
|
|
63
|
+
-- And stop the next one being born with it. The default privilege is what
|
|
64
|
+
-- created the hole, so narrowing the default is what stops it coming back on
|
|
65
|
+
-- the next view any migration creates.
|
|
66
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON TABLES FROM anon;
|
|
67
|
+
|
|
68
|
+
-- ── the census ─────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
CREATE OR REPLACE VIEW public.v_views_anon_can_write
|
|
71
|
+
WITH (security_invoker = true) AS
|
|
72
|
+
SELECT c.relname::text AS view_name,
|
|
73
|
+
c.relkind = 'm' AS materialized,
|
|
74
|
+
-- A view without security_invoker runs as its OWNER, so RLS does not
|
|
75
|
+
-- apply to what it reads. Writable AND definer is the straight bypass.
|
|
76
|
+
NOT coalesce((SELECT option_value = 'true' FROM pg_options_to_table(c.reloptions)
|
|
77
|
+
WHERE option_name = 'security_invoker'), false) AS runs_as_owner
|
|
78
|
+
FROM pg_class c
|
|
79
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
80
|
+
WHERE n.nspname = 'public'
|
|
81
|
+
AND c.relkind IN ('v', 'm')
|
|
82
|
+
AND (has_table_privilege('anon', c.oid, 'INSERT')
|
|
83
|
+
OR has_table_privilege('anon', c.oid, 'UPDATE')
|
|
84
|
+
OR has_table_privilege('anon', c.oid, 'DELETE'))
|
|
85
|
+
ORDER BY c.relname;
|
|
86
|
+
|
|
87
|
+
COMMENT ON VIEW public.v_views_anon_can_write IS
|
|
88
|
+
'Views an unauthenticated caller can write through (165). Zero is the expected reading. `runs_as_owner` true is the worse half: without security_invoker the view reads as its owner and RLS does not apply.';
|
|
89
|
+
|
|
90
|
+
GRANT SELECT ON public.v_views_anon_can_write TO authenticated, service_role;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 166_the_platform_billing_leftovers_go.sql — three Stripe-shaped tables that
|
|
3
|
+
-- were never the pool's business (#183).
|
|
4
|
+
--
|
|
5
|
+
-- `001_core.sql` created `invoices`, `payment_events` and `subscriptions` in the
|
|
6
|
+
-- Fayz Cloud era, when one database was meant to hold both moneys: what a
|
|
7
|
+
-- tenant's CUSTOMER pays the tenant, and what the TENANT pays Fayz. DECISIONS
|
|
8
|
+
-- 2026-07-24 split those — industry pools superseded Fayz Cloud, and platform
|
|
9
|
+
-- billing moved to the platform side.
|
|
10
|
+
--
|
|
11
|
+
-- What is left in the pool is the wrong half, and it costs more than the space:
|
|
12
|
+
-- these three compete for the word "invoice" with `plg_financial_invoices`,
|
|
13
|
+
-- which is exactly the confusion that made `plg_financial_titles` need renaming
|
|
14
|
+
-- in the first place. A reader who greps "invoice" in a pool should find one
|
|
15
|
+
-- answer.
|
|
16
|
+
--
|
|
17
|
+
-- ── Measured before dropping ───────────────────────────────────────────────
|
|
18
|
+
-- Zero rows on ecommerce and salon, the two largest pools. Zero readers in the
|
|
19
|
+
-- TypeScript: the only `'subscriptions'` matches are a nav-tab label in
|
|
20
|
+
-- plugin-courses, unrelated to this table.
|
|
21
|
+
--
|
|
22
|
+
-- ── `plans` STAYS ─────────────────────────────────────────────────────────
|
|
23
|
+
-- It is load-bearing and not a leftover: `agent_guard()` resolves role × plan ×
|
|
24
|
+
-- limits through `tenants.plan → plans.entitlements` in SQL, and 154's ceiling
|
|
25
|
+
-- reads `tenants.settings billing.plan_id`. Entitlements are enforced in the
|
|
26
|
+
-- database, so the catalogue belongs in the database.
|
|
27
|
+
--
|
|
28
|
+
-- ── Empty or nothing ──────────────────────────────────────────────────────
|
|
29
|
+
-- The drop is guarded on the table being EMPTY. A pool that turns out to hold
|
|
30
|
+
-- rows keeps them and says so — nobody's billing history is deleted to tidy a
|
|
31
|
+
-- schema, and finding rows here would mean the assumption above is wrong for
|
|
32
|
+
-- that pool, which is worth knowing rather than erasing.
|
|
33
|
+
-- ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
DO $$
|
|
36
|
+
DECLARE
|
|
37
|
+
v_table text;
|
|
38
|
+
v_rows bigint;
|
|
39
|
+
BEGIN
|
|
40
|
+
FOREACH v_table IN ARRAY ARRAY['payment_events', 'invoices', 'subscriptions']
|
|
41
|
+
LOOP
|
|
42
|
+
IF to_regclass('public.' || quote_ident(v_table)) IS NULL THEN CONTINUE; END IF;
|
|
43
|
+
|
|
44
|
+
EXECUTE format('SELECT count(*) FROM public.%I', v_table) INTO v_rows;
|
|
45
|
+
IF v_rows > 0 THEN
|
|
46
|
+
RAISE NOTICE '166: public.% holds % row(s) — kept. Platform billing was supposed to have moved off the pool; a row here says otherwise for this cluster.', v_table, v_rows;
|
|
47
|
+
CONTINUE;
|
|
48
|
+
END IF;
|
|
49
|
+
|
|
50
|
+
-- CASCADE only reaches views over an empty table, and any such view is
|
|
51
|
+
-- reporting on nothing. A dependent TABLE would be a foreign key, which
|
|
52
|
+
-- would have made the count non-zero on the other side.
|
|
53
|
+
EXECUTE format('DROP TABLE public.%I CASCADE', v_table);
|
|
54
|
+
RAISE NOTICE '166: dropped public.% — platform billing is the platform''s (DECISIONS 2026-07-24)', v_table;
|
|
55
|
+
END LOOP;
|
|
56
|
+
END $$;
|
|
57
|
+
|
|
58
|
+
-- What is left, for a pool where something was kept.
|
|
59
|
+
CREATE OR REPLACE VIEW public.v_platform_billing_leftovers
|
|
60
|
+
WITH (security_invoker = true) AS
|
|
61
|
+
SELECT c.relname::text AS table_name,
|
|
62
|
+
(SELECT n_live_tup FROM pg_stat_user_tables s WHERE s.relid = c.oid) AS approx_rows
|
|
63
|
+
FROM pg_class c
|
|
64
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
65
|
+
WHERE n.nspname = 'public'
|
|
66
|
+
AND c.relkind = 'r'
|
|
67
|
+
AND c.relname IN ('invoices', 'payment_events', 'subscriptions');
|
|
68
|
+
|
|
69
|
+
COMMENT ON VIEW public.v_platform_billing_leftovers IS
|
|
70
|
+
'Fayz Cloud-era platform billing tables still present in this pool (166, #183). Empty is the expected reading — 166 drops them when they hold nothing. A row here means that cluster still records what the TENANT pays Fayz, which belongs on the platform side.';
|
|
71
|
+
|
|
72
|
+
GRANT SELECT ON public.v_platform_billing_leftovers TO service_role;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 167_a_view_gets_back_what_its_table_grants.sql — undoing the half of 165 that
|
|
3
|
+
-- was too blunt.
|
|
4
|
+
--
|
|
5
|
+
-- ── What 165 got right, and what it broke ──────────────────────────────────
|
|
6
|
+
-- 165 closed a real hole: `ALTER DEFAULT PRIVILEGES` gives every view postgres
|
|
7
|
+
-- creates in `public` full write access for **anon**, and 85 of 94 views on the
|
|
8
|
+
-- ecommerce pool carried it. Revoking anon was correct and stays.
|
|
9
|
+
--
|
|
10
|
+
-- Revoking `authenticated` in the same sweep was not. A signed-in user's writes
|
|
11
|
+
-- are governed by **RLS**, which is the actual control — and several of these
|
|
12
|
+
-- views are not read models at all but **compat aliases** created by 141's
|
|
13
|
+
-- rename mechanism, whose entire purpose is that the old name keeps working
|
|
14
|
+
-- exactly as it did. A read-only alias does not make a rename unsynchronised;
|
|
15
|
+
-- it moves the breakage from "the table is gone" to "permission denied for
|
|
16
|
+
-- view", which is worse because it looks like a permissions bug.
|
|
17
|
+
--
|
|
18
|
+
-- Measured after 165, on the ecommerce pool: **94 views** lost INSERT for
|
|
19
|
+
-- `authenticated`. Most are read models nobody writes. These are not:
|
|
20
|
+
--
|
|
21
|
+
-- shipping_zones · shipping_settings the merchant's delivery screen —
|
|
22
|
+
-- `packages/shop/src/data/tables.ts`
|
|
23
|
+
-- still writes through the old name
|
|
24
|
+
-- plg_connections · plg_connection_secrets · plg_connector_schedules
|
|
25
|
+
-- connector setup
|
|
26
|
+
-- plg_sync_runs · plg_sync_schedule connector sync
|
|
27
|
+
-- plg_event_log the domain-event log's own alias
|
|
28
|
+
-- v_documents attachment uploads
|
|
29
|
+
--
|
|
30
|
+
-- ── The rule this file states ──────────────────────────────────────────────
|
|
31
|
+
-- A view gets back exactly what its underlying table grants — no more, no less.
|
|
32
|
+
-- That is 141's alias rule made mechanical instead of hand-written, and it is
|
|
33
|
+
-- self-correcting: a table that is tightened later tightens its alias on the
|
|
34
|
+
-- next run, and a view over a table nobody may write stays unwritable.
|
|
35
|
+
--
|
|
36
|
+
-- `anon` is excluded unconditionally. It is the role the hole was about, and no
|
|
37
|
+
-- alias needs it: an anonymous writer reaches the base table through its own
|
|
38
|
+
-- policy or not at all.
|
|
39
|
+
-- ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
DO $$
|
|
42
|
+
DECLARE
|
|
43
|
+
v_view record;
|
|
44
|
+
v_priv text;
|
|
45
|
+
v_fixed integer := 0;
|
|
46
|
+
BEGIN
|
|
47
|
+
FOR v_view IN
|
|
48
|
+
-- Only views over exactly ONE base table can be written through at all
|
|
49
|
+
-- (Postgres refuses the rest), so those are the only ones worth granting.
|
|
50
|
+
SELECT u.view_name, min(u.table_name) AS base_table
|
|
51
|
+
FROM information_schema.view_table_usage u
|
|
52
|
+
WHERE u.view_schema = 'public' AND u.table_schema = 'public'
|
|
53
|
+
GROUP BY u.view_name
|
|
54
|
+
HAVING count(DISTINCT u.table_name) = 1
|
|
55
|
+
LOOP
|
|
56
|
+
IF to_regclass('public.' || quote_ident(v_view.view_name)) IS NULL
|
|
57
|
+
OR to_regclass('public.' || quote_ident(v_view.base_table)) IS NULL THEN
|
|
58
|
+
CONTINUE;
|
|
59
|
+
END IF;
|
|
60
|
+
|
|
61
|
+
FOREACH v_priv IN ARRAY ARRAY['INSERT', 'UPDATE', 'DELETE']
|
|
62
|
+
LOOP
|
|
63
|
+
-- Mirror, never widen: the table decides.
|
|
64
|
+
IF has_table_privilege('authenticated', format('public.%I', v_view.base_table), v_priv)
|
|
65
|
+
AND NOT has_table_privilege('authenticated', format('public.%I', v_view.view_name), v_priv) THEN
|
|
66
|
+
EXECUTE format('GRANT %s ON public.%I TO authenticated', v_priv, v_view.view_name);
|
|
67
|
+
v_fixed := v_fixed + 1;
|
|
68
|
+
END IF;
|
|
69
|
+
END LOOP;
|
|
70
|
+
END LOOP;
|
|
71
|
+
|
|
72
|
+
RAISE NOTICE '167: restored % view privilege(s) to authenticated, mirroring the base table', v_fixed;
|
|
73
|
+
END $$;
|
|
74
|
+
|
|
75
|
+
-- anon stays out, whatever the table says. This is the half of 165 that was
|
|
76
|
+
-- right, restated so a re-run of this file cannot undo it.
|
|
77
|
+
DO $$
|
|
78
|
+
DECLARE
|
|
79
|
+
v_view record;
|
|
80
|
+
BEGIN
|
|
81
|
+
FOR v_view IN
|
|
82
|
+
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
83
|
+
WHERE n.nspname = 'public' AND c.relkind IN ('v', 'm')
|
|
84
|
+
AND (has_table_privilege('anon', c.oid, 'INSERT')
|
|
85
|
+
OR has_table_privilege('anon', c.oid, 'UPDATE')
|
|
86
|
+
OR has_table_privilege('anon', c.oid, 'DELETE'))
|
|
87
|
+
LOOP
|
|
88
|
+
EXECUTE format('REVOKE INSERT, UPDATE, DELETE, TRUNCATE ON public.%I FROM anon', v_view.relname);
|
|
89
|
+
END LOOP;
|
|
90
|
+
END $$;
|
|
91
|
+
|
|
92
|
+
-- ── the census, both directions ────────────────────────────────────────────
|
|
93
|
+
--
|
|
94
|
+
-- 165 gave us "which views can anon write". The other half was missing and is
|
|
95
|
+
-- what this file exists because of: which views an alias's users can NO LONGER
|
|
96
|
+
-- write, though the table beneath still allows it.
|
|
97
|
+
|
|
98
|
+
CREATE OR REPLACE VIEW public.v_views_missing_table_grants
|
|
99
|
+
WITH (security_invoker = true) AS
|
|
100
|
+
SELECT u.view_name::text,
|
|
101
|
+
min(u.table_name)::text AS base_table
|
|
102
|
+
FROM information_schema.view_table_usage u
|
|
103
|
+
WHERE u.view_schema = 'public' AND u.table_schema = 'public'
|
|
104
|
+
GROUP BY u.view_name
|
|
105
|
+
HAVING count(DISTINCT u.table_name) = 1
|
|
106
|
+
AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
|
|
107
|
+
AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', u.view_name), 'INSERT'));
|
|
108
|
+
|
|
109
|
+
COMMENT ON VIEW public.v_views_missing_table_grants IS
|
|
110
|
+
'Views a signed-in user can no longer write although the table beneath still allows it (167). Zero is the expected reading — a compat alias that lost its grants reads as a permissions bug on a screen, which is the most expensive kind of breakage to diagnose.';
|
|
111
|
+
|
|
112
|
+
GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 168_the_grant_census_stops_crying_wolf.sql — 167's census counted a view over
|
|
3
|
+
-- a view.
|
|
4
|
+
--
|
|
5
|
+
-- `information_schema.view_table_usage` reports every relation a view reads,
|
|
6
|
+
-- and a VIEW is one of them. So `plg_crm_rep_leads`, which reads `v_leads`,
|
|
7
|
+
-- looked like an alias missing its table's grants — when neither is writable
|
|
8
|
+
-- and neither ever will be.
|
|
9
|
+
--
|
|
10
|
+
-- A census that reads 1 forever is a census people learn to ignore, which is
|
|
11
|
+
-- worse than not having one. It now counts only views over an actual TABLE.
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
CREATE OR REPLACE VIEW public.v_views_missing_table_grants
|
|
15
|
+
WITH (security_invoker = true) AS
|
|
16
|
+
SELECT u.view_name::text,
|
|
17
|
+
min(u.table_name)::text AS base_table
|
|
18
|
+
FROM information_schema.view_table_usage u
|
|
19
|
+
JOIN pg_class c ON c.relname = u.table_name
|
|
20
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = u.table_schema
|
|
21
|
+
WHERE u.view_schema = 'public' AND u.table_schema = 'public'
|
|
22
|
+
-- A base that is itself a view cannot be written through either, so a
|
|
23
|
+
-- missing grant there is not a regression, it is the shape of the thing.
|
|
24
|
+
AND c.relkind = 'r'
|
|
25
|
+
GROUP BY u.view_name
|
|
26
|
+
HAVING count(DISTINCT u.table_name) = 1
|
|
27
|
+
AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
|
|
28
|
+
AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', u.view_name), 'INSERT'));
|
|
29
|
+
|
|
30
|
+
COMMENT ON VIEW public.v_views_missing_table_grants IS
|
|
31
|
+
'Views a signed-in user can no longer write although the TABLE beneath still allows it (167, narrowed by 168). Zero is the expected reading — a compat alias that lost its grants reads as a permissions bug on a screen, which is the most expensive kind of breakage to diagnose.';
|
|
32
|
+
|
|
33
|
+
GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 169_the_census_asks_postgres_instead_of_guessing.sql — third and last attempt
|
|
3
|
+
-- at the same census, this time asking the only source that knows.
|
|
4
|
+
--
|
|
5
|
+
-- The question is "which compat alias lost the write access its table has".
|
|
6
|
+
-- 167 approximated it as "a view reading exactly one relation", which counted
|
|
7
|
+
-- a view over a view. 168 narrowed to a view over a TABLE, which then counted
|
|
8
|
+
-- seven aggregate read models — `v_financial_ledger`, `v_invoice_balances`,
|
|
9
|
+
-- `plg_courses_rep_sales` and friends. Each reads one table and none is
|
|
10
|
+
-- writable, because they aggregate.
|
|
11
|
+
--
|
|
12
|
+
-- Both attempts were guessing at a property Postgres already computes:
|
|
13
|
+
-- `information_schema.views.is_insertable_into` is YES only for a view you can
|
|
14
|
+
-- actually INSERT through. A view that aggregates, joins, DISTINCTs or windows
|
|
15
|
+
-- is NO, and no grant changes that.
|
|
16
|
+
--
|
|
17
|
+
-- The lesson is worth more than the fix. Two rounds of a census reading
|
|
18
|
+
-- non-zero for a reason nobody needed to act on is exactly how a check gets
|
|
19
|
+
-- ignored — and a check people ignore is worse than no check, because it
|
|
20
|
+
-- occupies the space where a real one would go.
|
|
21
|
+
-- ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
CREATE OR REPLACE VIEW public.v_views_missing_table_grants
|
|
24
|
+
WITH (security_invoker = true) AS
|
|
25
|
+
SELECT v.table_name::text AS view_name,
|
|
26
|
+
min(u.table_name)::text AS base_table
|
|
27
|
+
FROM information_schema.views v
|
|
28
|
+
JOIN information_schema.view_table_usage u
|
|
29
|
+
ON u.view_schema = v.table_schema AND u.view_name = v.table_name
|
|
30
|
+
JOIN pg_class c ON c.relname = u.table_name
|
|
31
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = u.table_schema
|
|
32
|
+
WHERE v.table_schema = 'public'
|
|
33
|
+
AND u.table_schema = 'public'
|
|
34
|
+
-- The whole predicate, from the planner rather than from a heuristic.
|
|
35
|
+
AND v.is_insertable_into = 'YES'
|
|
36
|
+
AND c.relkind = 'r'
|
|
37
|
+
GROUP BY v.table_name
|
|
38
|
+
HAVING count(DISTINCT u.table_name) = 1
|
|
39
|
+
AND bool_or(has_table_privilege('authenticated', format('public.%I', u.table_name), 'INSERT'))
|
|
40
|
+
AND NOT bool_or(has_table_privilege('authenticated', format('public.%I', v.table_name), 'INSERT'));
|
|
41
|
+
|
|
42
|
+
COMMENT ON VIEW public.v_views_missing_table_grants IS
|
|
43
|
+
'Views a signed-in user can no longer write although the table beneath allows it AND Postgres considers the view insertable (167→169). Zero is the expected reading. An alias that lost its grants reads as a permissions bug on a screen — the most expensive kind to diagnose, and the reason 165 had to be narrowed.';
|
|
44
|
+
|
|
45
|
+
GRANT SELECT ON public.v_views_missing_table_grants TO authenticated, service_role;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"fayz": {
|
|
4
4
|
"status": "beta"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.13.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,
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"dist",
|
|
22
|
-
"migrations"
|
|
22
|
+
"migrations",
|
|
23
|
+
"canonical-schema.json"
|
|
23
24
|
],
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"drizzle-orm": "^0.36.4"
|
|
@@ -38,12 +39,12 @@
|
|
|
38
39
|
],
|
|
39
40
|
"peerDependencies": {},
|
|
40
41
|
"scripts": {
|
|
41
|
-
"build": "tsup && tsc
|
|
42
|
+
"build": "tsup && tsc -b --force",
|
|
42
43
|
"dev": "tsup --watch",
|
|
43
|
-
"typecheck": "tsc
|
|
44
|
+
"typecheck": "tsc -b",
|
|
44
45
|
"test": "bash test/run-migrations.sh",
|
|
45
46
|
"test:migrations": "bash test/run-migrations.sh",
|
|
46
47
|
"test:negative-control": "bash test/negative-control.sh",
|
|
47
|
-
"clean": "rm -rf dist"
|
|
48
|
+
"clean": "rm -rf dist .tsbuildinfo"
|
|
48
49
|
}
|
|
49
50
|
}
|