@fayz-ai/db 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/canonical-schema.json +132 -0
  2. package/dist/index.cjs +4 -4
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/schema/spine.d.ts +42 -37
  9. package/dist/schema/spine.d.ts.map +1 -1
  10. package/migrations/000_baseline.sql +29667 -0
  11. package/migrations/001_the_tenant_has_apps.sql +314 -0
  12. package/migrations/002_the_spine_drops_the_vendor_and_the_dead.sql +129 -0
  13. package/migrations/003_the_role_rpcs_finish_and_the_mirrors_go.sql +135 -0
  14. package/migrations/004_the_projections_get_a_successor.sql +312 -0
  15. package/migrations/005_the_archetypes_anchor_to_the_unit.sql +90 -0
  16. package/migrations/006_the_helpers_read_the_source.sql +153 -0
  17. package/migrations/007_the_compat_machinery_leaves_with_the_tables.sql +77 -0
  18. package/migrations/008_the_switchers_see_across_accounts.sql +150 -0
  19. package/migrations/009_the_compat_aliases_nobody_adopted.sql +57 -0
  20. package/migrations/010_the_new_tenant_is_born_with_its_trade.sql +201 -0
  21. package/migrations/011_leadcontrol_joins_the_family.sql +116 -0
  22. package/migrations/012_chefcontrol_wears_its_own_mark.sql +28 -0
  23. package/migrations/013_the_workspace_switch_reaches_the_other_half.sql +156 -0
  24. package/migrations/014_the_chooser_can_read_what_it_offers.sql +91 -0
  25. package/migrations/015_the_unit_bindings_belong_to_one_workspace.sql +86 -0
  26. package/migrations/016_the_rule_stops_asking_every_row.sql +543 -0
  27. package/migrations/017_the_pin_is_checked_against_the_right_workspace.sql +139 -0
  28. package/migrations/018_the_bootstrap_says_what_you_reach.sql +114 -0
  29. package/migrations/019_distributing_one_row_stops_costing_the_table.sql +245 -0
  30. package/migrations/020_the_agenda_asks_where_someone_works.sql +81 -0
  31. package/migrations/022_the_line_says_which_document_it_belongs_to.sql +109 -0
  32. package/package.json +3 -2
  33. package/migrations/0000_legacy_quarantine.sql +0 -86
  34. package/migrations/0000b_straggler_sweep.sql +0 -37
  35. package/migrations/000_core_v1_convert.sql +0 -343
  36. package/migrations/001_core.sql +0 -372
  37. package/migrations/002_rls_user_tenant_ids.sql +0 -64
  38. package/migrations/003_invitations.sql +0 -17
  39. package/migrations/004_archetypes.sql +0 -343
  40. package/migrations/005_locations_archetype.sql +0 -18
  41. package/migrations/006_archetype_rls.sql +0 -74
  42. package/migrations/007_archetype_grants.sql +0 -4
  43. package/migrations/008_grants.sql +0 -5
  44. package/migrations/009_anon_hardening.sql +0 -19
  45. package/migrations/010_migration_ledger.sql +0 -36
  46. package/migrations/011_anon_write_revoke.sql +0 -12
  47. package/migrations/012_rename_migration_ledger.sql +0 -61
  48. package/migrations/013_sequences_rls.sql +0 -22
  49. package/migrations/014_plan_entitlements.sql +0 -28
  50. package/migrations/015_agent_guard.sql +0 -108
  51. package/migrations/016_agent_guard_actor.sql +0 -102
  52. package/migrations/017_core_addresses.sql +0 -115
  53. package/migrations/018_global_search.sql +0 -482
  54. package/migrations/019_tenant_members_person_id.sql +0 -20
  55. package/migrations/020_fayz_projects.sql +0 -63
  56. package/migrations/021_tenant_roles.sql +0 -74
  57. package/migrations/022_invite_acceptance.sql +0 -91
  58. package/migrations/023_team_visibility.sql +0 -75
  59. package/migrations/024_analytics_engine.sql +0 -317
  60. package/migrations/025_created_by.sql +0 -75
  61. package/migrations/026_audit_trail.sql +0 -73
  62. package/migrations/027_domain_events.sql +0 -266
  63. package/migrations/028_tenant_scoped_token.sql +0 -127
  64. package/migrations/029_connections.sql +0 -186
  65. package/migrations/030_effect_idempotency.sql +0 -159
  66. package/migrations/031_sync_run_message.sql +0 -39
  67. package/migrations/032_connection_secrets.sql +0 -227
  68. package/migrations/033_sync_schedule.sql +0 -651
  69. package/migrations/034_custom_fields.sql +0 -55
  70. package/migrations/035_field_registry.sql +0 -148
  71. package/migrations/036_analytics_run_batch.sql +0 -84
  72. package/migrations/037_sync_tick_one_at_a_time.sql +0 -256
  73. package/migrations/038_onboarding_responses.sql +0 -103
  74. package/migrations/039_unit_tree.sql +0 -270
  75. package/migrations/040_resource_grants.sql +0 -474
  76. package/migrations/041_scoped_columns.sql +0 -192
  77. package/migrations/042_unit_scope_policies.sql +0 -145
  78. package/migrations/043_view_invoker.sql +0 -81
  79. package/migrations/044_unit_member_facts.sql +0 -47
  80. package/migrations/045_unit_entry.sql +0 -236
  81. package/migrations/046_membership_visible_to_members.sql +0 -85
  82. package/migrations/047_tasks.sql +0 -266
  83. package/migrations/048_every_login_is_a_person.sql +0 -190
  84. package/migrations/049_bookable_people.sql +0 -126
@@ -0,0 +1,156 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 013_the_workspace_switch_reaches_the_other_half.sql — the unit dropdown
3
+ -- stops listing other people's businesses.
4
+ --
5
+ -- THE BUG, as seen: the "Unidade" filter and the "Selecionar local…" picker in
6
+ -- a beauty account offered Américas Shopping, Depósito Central, Papa Léguas
7
+ -- Tacos and Mesa 1 — units belonging to eight OTHER tenants. Picking one wrote
8
+ -- a foreign unit id onto a booking. This is a tenant boundary failure, not a
9
+ -- filter that forgot a WHERE.
10
+ --
11
+ -- THE CAUSE, and it is one sentence: the database has TWO ideas of "my tenant"
12
+ -- and only one of them can hear the workspace switch.
13
+ --
14
+ -- app.current_tenant_id() — signed JWT claim, else the `x-fayz-tenant`
15
+ -- header the client sends, else "your only
16
+ -- account". This is the ACTIVE workspace.
17
+ -- public.user_tenant_ids() — every account you belong to. It reads the JWT
18
+ -- app.is_member(tenant) claim but has never read the HEADER, and nobody
19
+ -- mints a claim.
20
+ --
21
+ -- 145 added `x-fayz-tenant` so a two-account user could read public.people /
22
+ -- orders / products at all — those policies were already on current_tenant_id()
23
+ -- and were returning NOTHING. The other half of the schema was left on
24
+ -- user_tenant_ids(), where the same header is invisible. So the switch narrows
25
+ -- one half of the database and the other half keeps answering for all of them
26
+ -- at once: 133 tables, every plugin, plus app.units, app.roles and
27
+ -- app.role_permissions.
28
+ --
29
+ -- Nobody saw it on a single-account tenant, which is every real customer today
30
+ -- and every QA login but one. It is visible the moment somebody has two.
31
+ --
32
+ -- THE FIX: user_tenant_ids() means the ACTIVE workspace, like everything else.
33
+ -- The one caller that genuinely wants "every account of mine" — the app
34
+ -- switcher, whose whole job (008) is to reach across accounts — says so out
35
+ -- loud, through a function with that name.
36
+ -- ---------------------------------------------------------------------------
37
+
38
+ -- ── §1 "my tenant" means one tenant ───────────────────────────────────────
39
+ --
40
+ -- Delegating rather than re-deriving: the claim/header/single-account ladder is
41
+ -- subtle (a header pointing at an account that is not yours must DENY, not fall
42
+ -- back), it already exists in current_tenant_id, and two copies of it would be
43
+ -- two chances to disagree about who you are.
44
+
45
+ CREATE OR REPLACE FUNCTION public.user_tenant_ids() RETURNS SETOF uuid
46
+ LANGUAGE sql STABLE SECURITY DEFINER
47
+ SET search_path TO ''
48
+ AS $$
49
+ SELECT t.id FROM (SELECT app.current_tenant_id() AS id) t WHERE t.id IS NOT NULL;
50
+ $$;
51
+
52
+ COMMENT ON FUNCTION public.user_tenant_ids() IS
53
+ 'The ACTIVE workspace, as a set, for the ~130 policies written as `tenant_id IN (SELECT user_tenant_ids())` (013). It used to mean "every account I belong to", which made the workspace switch invisible to every table that used it. Cross-account readers want user_all_tenant_ids() and must say so.';
54
+
55
+ -- Admin rights are read the same way: which accounts you administer is not the
56
+ -- question a policy on THIS account's row is asking.
57
+ CREATE OR REPLACE FUNCTION public.user_admin_tenant_ids() RETURNS SETOF uuid
58
+ LANGUAGE sql STABLE SECURITY DEFINER
59
+ SET search_path TO ''
60
+ AS $$
61
+ SELECT m.tenant_id
62
+ FROM app.memberships m
63
+ JOIN app.roles r ON r.id = m.role_id
64
+ WHERE m.user_id = auth.uid() AND m.active
65
+ AND m.tenant_id = app.current_tenant_id()
66
+ AND r.key IN ('owner', 'admin');
67
+ $$;
68
+
69
+ COMMENT ON FUNCTION public.user_admin_tenant_ids() IS
70
+ 'Whether you administer the ACTIVE workspace, as a set (013). Narrowed for the same reason as user_tenant_ids: being an owner somewhere else is not permission here.';
71
+
72
+ -- ── §2 the switcher asks its own question ─────────────────────────────────
73
+
74
+ CREATE OR REPLACE FUNCTION public.user_all_tenant_ids() RETURNS SETOF uuid
75
+ LANGUAGE sql STABLE SECURITY DEFINER
76
+ SET search_path TO ''
77
+ AS $$
78
+ SELECT m.tenant_id FROM app.memberships m
79
+ WHERE m.user_id = auth.uid() AND m.active;
80
+ $$;
81
+
82
+ COMMENT ON FUNCTION public.user_all_tenant_ids() IS
83
+ 'Every account the caller belongs to (013). For the app/workspace switcher ONLY — the one screen whose question is "which of my accounts", asked before an account is chosen. A policy that reaches for this is claiming to be cross-account; almost none should be.';
84
+
85
+ REVOKE ALL ON FUNCTION public.user_all_tenant_ids() FROM PUBLIC;
86
+ GRANT EXECUTE ON FUNCTION public.user_all_tenant_ids() TO authenticated, service_role;
87
+
88
+ -- app.tenant_apps is that screen's table: v_tenant_apps (008) lists which of
89
+ -- MY accounts run the app I am looking at, and that list is drawn before the
90
+ -- account is picked. Narrowing it would leave the switcher with one row.
91
+ DROP POLICY IF EXISTS tenant_apps_read ON app.tenant_apps;
92
+ CREATE POLICY tenant_apps_read ON app.tenant_apps FOR SELECT TO authenticated
93
+ USING (tenant_id IN (SELECT public.user_all_tenant_ids()));
94
+
95
+ -- ── §3 the four app.* reads still on "any account of mine" ────────────────
96
+ --
97
+ -- Their siblings (units_insert/update/delete, roles_update, …) already compare
98
+ -- to current_tenant_id(). Only the SELECT was written with is_member, which is
99
+ -- why writing was scoped and READING was not.
100
+
101
+ DROP POLICY IF EXISTS units_select ON app.units;
102
+ CREATE POLICY units_select ON app.units FOR SELECT TO authenticated
103
+ USING (tenant_id = (SELECT app.current_tenant_id()));
104
+
105
+ DROP POLICY IF EXISTS roles_select ON app.roles;
106
+ CREATE POLICY roles_select ON app.roles FOR SELECT TO authenticated
107
+ USING (tenant_id = (SELECT app.current_tenant_id()));
108
+
109
+ DROP POLICY IF EXISTS role_permissions_select ON app.role_permissions;
110
+ CREATE POLICY role_permissions_select ON app.role_permissions FOR SELECT TO authenticated
111
+ USING (tenant_id = (SELECT app.current_tenant_id()));
112
+
113
+ DROP POLICY IF EXISTS resource_grants_select ON app.resource_grants;
114
+ CREATE POLICY resource_grants_select ON app.resource_grants FOR SELECT TO authenticated
115
+ USING (tenant_id = (SELECT app.current_tenant_id()));
116
+
117
+ -- ── §4 a table with no boundary at all ────────────────────────────────────
118
+ --
119
+ -- USING (true): which stock engine each tenant's products are counted by, plus
120
+ -- their product ids, readable by anybody with a login to anything. 146 wrote it
121
+ -- open on the argument that the row is "configuration"; a row keyed by
122
+ -- tenant_id and product_id is that tenant's, whatever it holds.
123
+
124
+ DROP POLICY IF EXISTS product_stock_source_read ON app.product_stock_source;
125
+ CREATE POLICY product_stock_source_read ON app.product_stock_source FOR SELECT TO authenticated
126
+ USING (tenant_id = (SELECT app.current_tenant_id()));
127
+
128
+ -- ── §5 the legacy role tables ─────────────────────────────────────────────
129
+ --
130
+ -- tenant_roles_read is older still: it joins public.tenant_members, which 007
131
+ -- removed, so the policy is dead weight that would come back to life — wide —
132
+ -- if anything ever recreated that name.
133
+
134
+ -- Guarded: a pool that never took the permission-profiles tables has neither,
135
+ -- and a boundary fix is not the place to demand them.
136
+ DO $$
137
+ BEGIN
138
+ IF to_regclass('public.tenant_roles') IS NOT NULL THEN
139
+ DROP POLICY IF EXISTS tenant_roles_read ON public.tenant_roles;
140
+ DROP POLICY IF EXISTS tenant_roles_select ON public.tenant_roles;
141
+ CREATE POLICY tenant_roles_select ON public.tenant_roles FOR SELECT TO authenticated
142
+ USING (tenant_id = (SELECT app.current_tenant_id()));
143
+ END IF;
144
+
145
+ IF to_regclass('public.tenant_role_overrides') IS NOT NULL THEN
146
+ DROP POLICY IF EXISTS overrides_select ON public.tenant_role_overrides;
147
+ CREATE POLICY overrides_select ON public.tenant_role_overrides FOR SELECT TO authenticated
148
+ USING (tenant_id = (SELECT app.current_tenant_id()));
149
+ END IF;
150
+ END $$;
151
+
152
+ -- ── §6 the audit row is written into the account it happened in ───────────
153
+
154
+ DROP POLICY IF EXISTS audit_insert ON public.audit_logs;
155
+ CREATE POLICY audit_insert ON public.audit_logs FOR INSERT TO authenticated
156
+ WITH CHECK (tenant_id = (SELECT app.current_tenant_id()));
@@ -0,0 +1,91 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 014_the_chooser_can_read_what_it_offers.sql — the workspace picker stops
3
+ -- asking a question it is not allowed to hear the answer to.
4
+ --
5
+ -- THE BUG, as seen: an account belonging to seven workspaces signs in and the
6
+ -- shell offers "Nova área de trabalho" — as if it belonged to none. The
7
+ -- browser console shows the real failure a layer down:
8
+ --
9
+ -- TypeError: Cannot read properties of null (reading 'charAt')
10
+ -- at capitalize (admin/src/org/adapters/supabase.ts)
11
+ -- at async loadOrgs
12
+ --
13
+ -- THE CAUSE: 013 finished half a thought. It redefined user_tenant_ids() to
14
+ -- mean the ACTIVE workspace — correct, and the fix for 133 leaking tables —
15
+ -- and it understood that the SWITCHER is the one screen that must read across
16
+ -- accounts, because it runs BEFORE an account is chosen. But it granted that
17
+ -- exception to exactly one table, app.tenant_apps, and the switcher reads
18
+ -- three:
19
+ --
20
+ -- app.tenant_apps — which of my accounts run this app. 013 fixed it.
21
+ -- public.tenants — what those accounts are CALLED. Still narrow.
22
+ -- app.roles — what I am in each of them. Still narrow.
23
+ --
24
+ -- Before a choice exists current_tenant_id() is NULL, so the last two answer
25
+ -- with nothing: v_team_members returns the seven memberships (its own table
26
+ -- lets you see your own rows) with `tenant` null and `role` null. The client
27
+ -- capitalises the role, hits null, throws, and the whole membership load dies
28
+ -- with it — which is why the screen says "no workspace" rather than "seven
29
+ -- workspaces with blank names".
30
+ --
31
+ -- It is a chicken-and-egg, and it is invisible on a single-workspace account:
32
+ -- there the client's own fallback picks the only membership before anything
33
+ -- needs a name. Every real customer today is single-workspace. Every QA login
34
+ -- but one is too.
35
+ --
36
+ -- THE FIX: the two remaining reads on the pre-selection path say out loud that
37
+ -- they are cross-account, the same way 013 made app.tenant_apps say it. This
38
+ -- restores exactly what these two tables answered BEFORE 013 and nothing more
39
+ -- — 013 narrowed them as collateral, not as intent; its subject was units and
40
+ -- bookings leaking to NON-members, which neither of these ever did.
41
+ -- ---------------------------------------------------------------------------
42
+
43
+ -- ── §1 the accounts have names again ──────────────────────────────────────
44
+ --
45
+ -- A workspace row is readable when you are a MEMBER of that workspace — which
46
+ -- is what the policy said until 013, and what the picker needs to draw a list
47
+ -- you can choose from. Membership, not the active choice, is the boundary
48
+ -- here: the rows are your own accounts either way.
49
+
50
+ DROP POLICY IF EXISTS tenants_select ON public.tenants;
51
+ CREATE POLICY tenants_select ON public.tenants FOR SELECT TO authenticated
52
+ USING (id IN (SELECT public.user_all_tenant_ids()));
53
+
54
+ COMMENT ON POLICY tenants_select ON public.tenants IS
55
+ 'Every workspace you belong to, named (014). Cross-account on purpose: the picker that chooses the active workspace has to render the list before one exists, so scoping this to the active choice makes the choice unmakeable.';
56
+
57
+ -- ── §2 my own role travels with my own membership ─────────────────────────
58
+ --
59
+ -- Not the whole role TABLE of every account — 013 was right that the role list
60
+ -- of a workspace you are not looking at is none of this request's business.
61
+ -- The narrow thing the picker needs is one row per membership: the role I hold
62
+ -- THERE, which v_team_members joins to turn a role_id into a key ('owner').
63
+ --
64
+ -- SECURITY DEFINER for the same reason the 013 helpers are: a policy
65
+ -- expression is evaluated as the caller, and reaching into app.memberships
66
+ -- from inside a policy that app.memberships' own policies can reach back into
67
+ -- is how recursion starts.
68
+
69
+ CREATE OR REPLACE FUNCTION public.user_own_role_ids() RETURNS SETOF uuid
70
+ LANGUAGE sql STABLE SECURITY DEFINER
71
+ SET search_path TO ''
72
+ AS $$
73
+ SELECT m.role_id FROM app.memberships m
74
+ WHERE m.user_id = auth.uid() AND m.active AND m.role_id IS NOT NULL;
75
+ $$;
76
+
77
+ COMMENT ON FUNCTION public.user_own_role_ids() IS
78
+ 'The roles the caller personally holds, across every account they belong to (014). For the workspace picker, which shows you what you are in each — not for reading an account''s role list, which stays on the active workspace.';
79
+
80
+ REVOKE ALL ON FUNCTION public.user_own_role_ids() FROM PUBLIC;
81
+ GRANT EXECUTE ON FUNCTION public.user_own_role_ids() TO authenticated, service_role;
82
+
83
+ DROP POLICY IF EXISTS roles_select ON app.roles;
84
+ CREATE POLICY roles_select ON app.roles FOR SELECT TO authenticated
85
+ USING (
86
+ tenant_id = (SELECT app.current_tenant_id())
87
+ OR id IN (SELECT public.user_own_role_ids())
88
+ );
89
+
90
+ COMMENT ON POLICY roles_select ON app.roles IS
91
+ 'The active workspace''s roles (013), plus the caller''s OWN role in each account they belong to (014) — the second clause is what lets the workspace picker say "Owner" next to a workspace you have not entered yet.';
@@ -0,0 +1,86 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 015_the_unit_bindings_belong_to_one_workspace.sql — the Unidades menu stops
3
+ -- going blank in the workspace you are actually standing in.
4
+ --
5
+ -- THE BUG, as seen: "Espaço Facial não lista as unidades". The account has four
6
+ -- ACTIVE units in that workspace and the menu drew its "Unidades" heading with
7
+ -- nothing underneath it.
8
+ --
9
+ -- THE CAUSE: `unit_bootstrap` filters every clause it has by p_tenant_id — the
10
+ -- units, the scoping mode, both pins — except one:
11
+ --
12
+ -- 'bindings', ( SELECT ... FROM public.v_unit_members lm
13
+ -- WHERE lm.user_id = auth.uid() ) -- and nothing else
14
+ --
15
+ -- RLS does not save it. `membership_units_select` lets you read your OWN rows
16
+ -- unconditionally (`user_id = auth.uid() OR ...`) — it has to, or you could not
17
+ -- find yourself — and "your own rows" spans every account you belong to. So the
18
+ -- call returned a binding to `Ideale 633`, a unit of Espaco Renova Rio, while
19
+ -- the caller was standing in Espaço Facial.
20
+ --
21
+ -- The client then does the reasonable thing with an unreasonable answer.
22
+ -- UnitMenuSection narrows the list to what you are bound to:
23
+ --
24
+ -- reachable = myUnitIds.length > 0 ? myUnitIds : <every unit>
25
+ --
26
+ -- One foreign binding is enough to take the first branch, and no unit of THIS
27
+ -- tenant is in it. Not an empty list — a list of somewhere else. The fallback
28
+ -- that would have shown all four units never runs.
29
+ --
30
+ -- Invisible on a single-workspace account, like everything else in this family:
31
+ -- there, every binding you have is in the only tenant you are in.
32
+ --
33
+ -- THE FIX: the one clause that forgot says which workspace it means, like its
34
+ -- four siblings in the same function.
35
+ -- ---------------------------------------------------------------------------
36
+
37
+ CREATE OR REPLACE FUNCTION public.unit_bootstrap(p_tenant_id uuid, p_device_id text DEFAULT NULL::text)
38
+ RETURNS jsonb
39
+ LANGUAGE sql
40
+ STABLE
41
+ SET search_path TO 'public', 'pg_temp'
42
+ AS $function$
43
+ SELECT jsonb_build_object(
44
+ 'mode', COALESCE((
45
+ SELECT t.unit_scoping_enabled FROM public.tenants t WHERE t.id = p_tenant_id
46
+ ), false),
47
+ 'units', COALESCE((
48
+ SELECT jsonb_agg(to_jsonb(l) ORDER BY l.is_headquarters DESC NULLS LAST, l.name)
49
+ FROM public.v_units l WHERE l.tenant_id = p_tenant_id
50
+ ), '[]'::jsonb),
51
+ -- The tenant filter (015). Without it a binding from another workspace
52
+ -- comes back here and the switcher narrows to a unit this tenant does not
53
+ -- have, which reads on screen as "this account has no units".
54
+ 'bindings', COALESCE((
55
+ SELECT jsonb_agg(jsonb_build_object('location_id', lm.location_id, 'role', lm.role))
56
+ FROM public.v_unit_members lm
57
+ WHERE lm.user_id = (SELECT auth.uid())
58
+ AND lm.tenant_id = p_tenant_id
59
+ ), '[]'::jsonb),
60
+ 'scoped', COALESCE((
61
+ SELECT jsonb_object_agg(sr.resource_table, jsonb_build_object(
62
+ 'unit_column', sr.unit_column, 'shareable', sr.shareable))
63
+ FROM public.scoped_resources sr
64
+ ), '{}'::jsonb),
65
+ 'device_pin', (
66
+ SELECT p.unit_id FROM public.user_unit_prefs p
67
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
68
+ ),
69
+ 'device_pin_set', EXISTS (
70
+ SELECT 1 FROM public.user_unit_prefs p
71
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id AND p.device_id = p_device_id
72
+ ),
73
+ 'account_pin', (
74
+ SELECT p.unit_id FROM public.user_unit_prefs p
75
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
76
+ ORDER BY p.updated_at DESC LIMIT 1
77
+ ),
78
+ 'account_pin_set', EXISTS (
79
+ SELECT 1 FROM public.user_unit_prefs p
80
+ WHERE p.user_id = (SELECT auth.uid()) AND p.tenant_id = p_tenant_id
81
+ )
82
+ );
83
+ $function$;
84
+
85
+ COMMENT ON FUNCTION public.unit_bootstrap(uuid, text) IS
86
+ 'Everything the unit switcher needs for ONE workspace, in one round trip. Every clause is scoped to p_tenant_id — including bindings, which was not, and leaked a unit of another account into this one''s menu (015).';