Skip to content

Commit 999550f

Browse files
wip v28
1 parent 3219919 commit 999550f

8 files changed

Lines changed: 335 additions & 3 deletions

File tree

BITCODE_SPEC_V28_NOTES.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,12 @@ This recalibration means the QA document, parity matrix, and implementation work
6666

6767
Supabase/PostgreSQL data-health checks are part of the V28 acceptance apparatus, not a separate operator convenience. The ORM package now owns reusable schema, identity, Terminal, ledger, and operational check manifests plus live runners for daily staging/production validation, CI E2E validation, local QA reports, and generated schema-type coverage. These checks must preserve the core boundary: Supabase rows are ledger-derived or Bitcode-canonical projections, while ledger anchors, signed wallet proofs, Terminal journal receipts, and canonical receipts remain the source of cryptographic truth.
6868

69-
May 14 staging-testnet verification added the first repository-owned RLS repairs after the dashboard-origin RLS bootstrap. `20260514173000_enable_pipeline_runs_rls.sql` enables RLS on `public.pipeline_runs`, and `20260514175000_enable_pipeline_runtime_rls.sql` enables RLS on server-managed pipeline runtime tables reported by Supabase Security Advisor. These preserve existing service-role pipeline posture and move runtime internals to deny-by-default for browser roles unless a narrower policy is later specified. The live daily data-health suite now passes 24/24 checks against the staging Supabase project, live ORM E2E tests can load `.env.local` directly for local QA parity with the CLI runner, and the advisory-style RLS probe returns no public tables with policies/browser grants while RLS is disabled.
69+
May 14 staging-testnet verification added the first repository-owned RLS repairs after the dashboard-origin RLS bootstrap. `20260514173000_enable_pipeline_runs_rls.sql` enables RLS on `public.pipeline_runs`, and `20260514175000_enable_pipeline_runtime_rls.sql` enables RLS on server-managed pipeline runtime tables reported by Supabase Security Advisor. These preserve existing service-role pipeline posture and move runtime internals to deny-by-default for browser roles unless a narrower policy is later specified. The live daily data-health suite now passes 25/25 checks against the staging Supabase project, live ORM E2E tests can load `.env.local` directly for local QA parity with the CLI runner, and the advisory-style RLS probe returns no public tables with policies/browser grants while RLS is disabled.
7070

7171
May 14 live deployment QA proved the custom Bitcoin OAuth path through Supabase Auth after the project Site URL was corrected from localhost to `https://bitcode.exchange`. The Supabase custom provider created the expected `auth.users` and `auth.identities` rows for `custom:bitcode-bitcoin`; Bitcode then projected the same user into `public.user_profiles.settings.bitcodeProfile.walletBinding` and `public.user_connections.provider='leather'`. V28 QA SQL and data-health checks therefore read wallet binding through the canonical JSON settings path, not nonexistent flat `user_profiles.wallet_*` columns.
7272

73+
Fresh-nuke onboarding then exposed a timing gap: Supabase Auth and the empty trigger-created profile existed immediately, while Bitcode's wallet projection appeared only after the app remounted the Wallet pane and replayed the locally signed proof. V28 now treats that gap as unacceptable for first-run readiness. A route-global wallet session persistence bridge must replay any locally signed Bitcoin wallet proof once a Supabase session exists, so callback-to-root, hard refresh, or Terminal landing all converge on the same `user_profiles.settings.bitcodeProfile.walletBinding` and `user_connections` projection before GitHub onboarding proceeds.
74+
7375
### Deterministic Model Boundary
7476

7577
V28 must remove the idea that a user can choose ledgerized Bitcode synthesis models.

BITCODE_V28_QA.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,7 @@ Deployment judgment:
850850
- Code/build readiness: pass.
851851
- Wallet/OAuth/GitHub source readiness in code: pass.
852852
- Formal protocol and ORM migration source readiness: pass.
853-
- Live staging data-plane readiness: pass baseline. `pnpm db:data-health:daily` checked 24 and passed 24 against `tkpyosihuouusyaxtbau`; the advisor-style RLS probe returned zero rows; saved-query verification still belongs in the first-run onboarding QA evidence.
853+
- Live staging data-plane readiness: pass baseline. `pnpm db:data-health:daily` checked 25 and passed 25 against `tkpyosihuouusyaxtbau`; the advisor-style RLS probe returned zero rows; saved-query verification still belongs in the first-run onboarding QA evidence.
854854
- Manual first-run onboarding may proceed after deployment env is confirmed; capture saved-query evidence during the pass.
855855
856856
2026-05-14 first live deployment onboarding evidence:
@@ -859,6 +859,7 @@ Deployment judgment:
859859
- After correcting the Site URL, Supabase Auth created `auth.users` and `auth.identities` rows with `provider='custom:bitcode-bitcoin'` and the Bitcoin testnet subject.
860860
- The matching Bitcode application projection exists in `public.user_profiles` under `settings.bitcodeProfile.walletBinding`, with the same user UUID as `auth.users.id`, and `public.user_connections` has a matching `provider='leather'` wallet row.
861861
- The saved `v28_qa_03_user_profiles_wallet_binding` query was corrected to read wallet binding from JSON settings rather than nonexistent flat `wallet_address`, `wallet_provider`, and `wallet_binding_status` columns.
862+
- Fresh staging nuke/re-onboarding showed the intended first two Auth rows immediately but left `user_profiles.settings` empty until the app replayed the signed wallet proof from local storage. V28 now treats this as a first-run integrity gap: any active route must persist the signed local wallet proof once a Supabase session exists, so a callback-to-root landing still converges on `settings.bitcodeProfile.walletBinding` and `user_connections` before GitHub onboarding.
862863
863864
## Issue Template
864865

packages/orm/src/data-health/checks.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,85 @@ export const DATA_HEALTH_CHECKS: DataHealthCheckDefinition[] = [
372372
WHERE conname = 'user_connections_provider_check';
373373
`,
374374
},
375+
{
376+
id: 'identity.bitcoin-auth-users-projected',
377+
title: 'Bitcoin Auth users are projected into Bitcode wallet state',
378+
category: 'identity',
379+
severity: 'critical',
380+
description: 'Ensures every Supabase custom Bitcoin Auth identity has a matching Bitcode profile wallet binding and wallet connection projection.',
381+
remediation: 'Reload the app after wallet authentication so the wallet session persistence bridge can write the profile and connection projection; if drift remains, inspect /api/wallet/authenticate logs.',
382+
requires: ['public.user_profiles', 'public.user_connections'],
383+
sql: `
384+
WITH bitcoin_identities AS (
385+
SELECT
386+
user_id,
387+
identity_data ->> 'sub' AS subject,
388+
split_part(identity_data ->> 'sub', ':', 3) AS address,
389+
updated_at
390+
FROM auth.identities
391+
WHERE provider = 'custom:bitcode-bitcoin'
392+
),
393+
projected AS (
394+
SELECT
395+
i.user_id,
396+
i.subject,
397+
i.address,
398+
coalesce(
399+
p.settings #>> '{bitcodeProfile,walletBinding,address}',
400+
p.settings #>> '{walletBinding,address}'
401+
) AS profile_address,
402+
coalesce(
403+
p.settings #>> '{bitcodeProfile,walletBinding,provider}',
404+
p.settings #>> '{walletBinding,provider}'
405+
) AS profile_provider,
406+
c.id AS connection_id,
407+
coalesce(
408+
c.connection_data ->> 'address',
409+
c.connection_data ->> 'wallet_address',
410+
c.connection_data ->> 'provider_user_id'
411+
) AS connection_address
412+
FROM bitcoin_identities i
413+
LEFT JOIN public.user_profiles p
414+
ON p.id = i.user_id
415+
LEFT JOIN public.user_connections c
416+
ON c.user_id = i.user_id
417+
AND c.provider IN ('bitcoin-wallet', 'unisat', 'leather', 'okx-bitcoin', 'xverse', 'manual-bitcoin')
418+
AND coalesce(c.is_active, true) = true
419+
),
420+
drift AS (
421+
SELECT *
422+
FROM projected
423+
WHERE
424+
nullif(trim(coalesce(address, '')), '') IS NULL
425+
OR profile_address IS DISTINCT FROM address
426+
OR nullif(trim(coalesce(profile_provider, '')), '') IS NULL
427+
OR connection_id IS NULL
428+
OR connection_address IS DISTINCT FROM address
429+
)
430+
SELECT
431+
count(*) = 0 AS ok,
432+
count(*)::bigint AS observed_count,
433+
jsonb_build_object(
434+
'unprojected_bitcoin_auth_identities',
435+
coalesce(
436+
jsonb_agg(
437+
jsonb_build_object(
438+
'user_id', user_id,
439+
'subject', subject,
440+
'address', address,
441+
'profile_provider', profile_provider,
442+
'profile_address', profile_address,
443+
'connection_id', connection_id,
444+
'connection_address', connection_address
445+
)
446+
ORDER BY user_id::text
447+
),
448+
'[]'::jsonb
449+
)
450+
) AS details
451+
FROM drift;
452+
`,
453+
},
375454
{
376455
id: 'identity.wallet-profile-connection-parity',
377456
title: 'Wallet profile bindings match connection rows',

supabase/DATA_HEALTH.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ pnpm db:schema-types:check
9090
| Suite | Purpose |
9191
| --- | --- |
9292
| `schema` | Required tables, required columns, extensions, migrations, RLS, provider-scope readiness. |
93-
| `identity` | Wallet profile/connection parity, signed Bitcoin wallet row shape, GitHub installation row shape, repository cache addressability. |
93+
| `identity` | Custom Bitcoin Auth projection, wallet profile/connection parity, signed Bitcoin wallet row shape, GitHub installation row shape, repository cache addressability. |
9494
| `terminal` | Terminal journal replay order and AssetPack mint journal coverage. |
9595
| `ledger` | BTD supply, measuremint receipts, AssetPack ranges, cells, anchors, BTC fees, revenue conservation, reconciliation repairs. |
9696
| `operational` | Recent critical crypto telemetry and application error rows. |
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from 'react';
4+
5+
import { createClient } from '@bitcode/supabase/ssr/client';
6+
7+
import { mutateUserData } from '@/hooks/useUserData';
8+
import {
9+
BITCODE_LOCAL_WALLET_EVENT,
10+
readLocalBitcodeWalletIdentity,
11+
writeLocalBitcodeWalletIdentity,
12+
type BitcodeWalletBindingStatus,
13+
type LocalBitcodeWalletIdentity,
14+
} from '@/lib/bitcode-wallet-local';
15+
import { bitcodeQaTelemetry, compactBitcodeAddress } from '@/lib/bitcode-qa-telemetry';
16+
17+
function canPersistWalletIdentity(identity: LocalBitcodeWalletIdentity | null): identity is LocalBitcodeWalletIdentity {
18+
return Boolean(
19+
identity &&
20+
identity.persistence !== 'server' &&
21+
identity.proofKind === 'bitcoin_message_signature' &&
22+
identity.message &&
23+
identity.signature,
24+
);
25+
}
26+
27+
function readPersistedStatus(payload: unknown, fallback: BitcodeWalletBindingStatus): BitcodeWalletBindingStatus {
28+
const status = (payload as any)?.walletConnectionStatus?.verificationState;
29+
return status === 'verified' || status === 'manual' || status === 'pending' ? status : fallback;
30+
}
31+
32+
function readPersistedAt(payload: unknown, fallback: string) {
33+
const connectedAt = (payload as any)?.walletConnectionStatus?.metadata?.connectedAt;
34+
return typeof connectedAt === 'string' && connectedAt.trim() ? connectedAt.trim() : fallback;
35+
}
36+
37+
export default function WalletSessionPersistenceBridge() {
38+
const inFlightKeyRef = useRef<string | null>(null);
39+
40+
useEffect(() => {
41+
let cancelled = false;
42+
43+
const persistLocalWalletIdentity = async (reason: string) => {
44+
const identity = readLocalBitcodeWalletIdentity();
45+
if (!canPersistWalletIdentity(identity)) return;
46+
47+
const persistenceKey = `${identity.provider}:${identity.address}:${identity.signature}`;
48+
if (inFlightKeyRef.current === persistenceKey) return;
49+
inFlightKeyRef.current = persistenceKey;
50+
51+
try {
52+
const supabase = createClient();
53+
const existing = await supabase.auth.getUser();
54+
if (cancelled || !existing.data.user) {
55+
inFlightKeyRef.current = null;
56+
return;
57+
}
58+
59+
bitcodeQaTelemetry('info', 'wallet-session', 'persist-start', {
60+
reason,
61+
provider: identity.provider,
62+
address: compactBitcodeAddress(identity.address),
63+
});
64+
65+
const response = await fetch('/api/wallet/authenticate', {
66+
method: 'POST',
67+
headers: { 'Content-Type': 'application/json' },
68+
body: JSON.stringify({
69+
address: identity.address,
70+
provider: identity.provider,
71+
network: identity.network,
72+
message: identity.message,
73+
signature: identity.signature,
74+
proofKind: identity.proofKind,
75+
paymentAddress: identity.paymentAddress,
76+
authAddress: identity.authAddress,
77+
addressType: identity.addressType,
78+
connectedAt: identity.connectedAt,
79+
issuedAt: identity.connectedAt,
80+
}),
81+
});
82+
const payload = await response.json().catch(() => null);
83+
84+
if (!response.ok) {
85+
inFlightKeyRef.current = null;
86+
bitcodeQaTelemetry('warn', 'wallet-session', 'persist-failed', {
87+
status: response.status,
88+
error: typeof payload?.error === 'string' ? payload.error : null,
89+
});
90+
return;
91+
}
92+
93+
if (cancelled) return;
94+
95+
writeLocalBitcodeWalletIdentity({
96+
...identity,
97+
status: readPersistedStatus(payload, identity.status),
98+
connectedAt: readPersistedAt(payload, identity.connectedAt),
99+
persistence: 'server',
100+
});
101+
await mutateUserData();
102+
bitcodeQaTelemetry('info', 'wallet-session', 'persist-success', {
103+
provider: identity.provider,
104+
address: compactBitcodeAddress(identity.address),
105+
});
106+
} catch (error) {
107+
inFlightKeyRef.current = null;
108+
bitcodeQaTelemetry('warn', 'wallet-session', 'persist-error', {
109+
message: error instanceof Error ? error.message : String(error),
110+
});
111+
}
112+
};
113+
114+
void persistLocalWalletIdentity('mount');
115+
116+
const handleWalletChange = () => {
117+
void persistLocalWalletIdentity('local-wallet-change');
118+
};
119+
const handleFocus = () => {
120+
void persistLocalWalletIdentity('window-focus');
121+
};
122+
123+
window.addEventListener(BITCODE_LOCAL_WALLET_EVENT, handleWalletChange);
124+
window.addEventListener('focus', handleFocus);
125+
return () => {
126+
cancelled = true;
127+
window.removeEventListener(BITCODE_LOCAL_WALLET_EVENT, handleWalletChange);
128+
window.removeEventListener('focus', handleFocus);
129+
};
130+
}, []);
131+
132+
return null;
133+
}

uapi/app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { SpeedInsights } from "@vercel/speed-insights/next"
1717
import { GoogleAnalytics } from '@next/third-parties/google'
1818
import AnalyticsEventsClient from '@/components/base/bitcode/analytics/AnalyticsEventsClient';
1919
import PageAnalyticsClient from '@/components/base/bitcode/analytics/PageAnalyticsClient';
20+
import WalletSessionPersistenceBridge from './WalletSessionPersistenceBridge';
2021
import { init as initSentry } from '@bitcode/sentry';
2122

2223
initSentry({
@@ -78,6 +79,7 @@ export default function RootLayout({
7879
className={`${inter.className} z-20 overflow-x-hidden`}
7980
>
8081
{children}
82+
<WalletSessionPersistenceBridge />
8183
<AnalyticsEventsClient />
8284
<Suspense fallback={null}>
8385
<PageAnalyticsClient />

uapi/jest.config.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ module.exports = {
133133
'<rootDir>/tests/walletPane.static.test.tsx',
134134
'<rootDir>/tests/walletPane.initialFlow.test.tsx',
135135
'<rootDir>/tests/walletPane.test.tsx',
136+
'<rootDir>/tests/walletSessionPersistenceBridge.test.tsx',
136137
'<rootDir>/tests/externalsPane.dataShareFlow.test.tsx',
137138
'<rootDir>/tests/walletPane.integration.test.tsx',
138139
'<rootDir>/tests/navPublicShell.test.tsx',
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import '@testing-library/jest-dom';
2+
import React from 'react';
3+
import { render, waitFor } from '@testing-library/react';
4+
5+
import WalletSessionPersistenceBridge from '@/app/WalletSessionPersistenceBridge';
6+
import {
7+
BITCODE_LOCAL_WALLET_STORAGE_KEY,
8+
readLocalBitcodeWalletIdentity,
9+
} from '@/lib/bitcode-wallet-local';
10+
import { mutateUserData } from '@/hooks/useUserData';
11+
12+
const getUser = jest.fn();
13+
14+
jest.mock('@bitcode/supabase/ssr/client', () => ({
15+
createClient: jest.fn(() => ({
16+
auth: {
17+
getUser,
18+
},
19+
})),
20+
}));
21+
22+
jest.mock('@/hooks/useUserData', () => ({
23+
mutateUserData: jest.fn(async () => undefined),
24+
}));
25+
26+
jest.mock('@/lib/bitcode-qa-telemetry', () => ({
27+
bitcodeQaTelemetry: jest.fn(),
28+
compactBitcodeAddress: (value: string | null | undefined) => value ?? null,
29+
}));
30+
31+
const mockMutateUserData = mutateUserData as jest.MockedFunction<typeof mutateUserData>;
32+
33+
describe('WalletSessionPersistenceBridge', () => {
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
window.localStorage.clear();
37+
getUser.mockResolvedValue({ data: { user: { id: 'user-1' } }, error: null });
38+
global.fetch = jest.fn(async () => ({
39+
ok: true,
40+
status: 200,
41+
json: async () => ({
42+
walletConnectionStatus: {
43+
verificationState: 'pending',
44+
metadata: {
45+
connectedAt: '2026-05-14T14:08:42.562Z',
46+
},
47+
},
48+
}),
49+
})) as jest.Mock;
50+
});
51+
52+
it('persists a locally signed wallet proof once a Supabase session exists', async () => {
53+
window.localStorage.setItem(
54+
BITCODE_LOCAL_WALLET_STORAGE_KEY,
55+
JSON.stringify({
56+
address: 'tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2',
57+
provider: 'leather',
58+
network: 'testnet',
59+
status: 'pending',
60+
connectedAt: '2026-05-14T14:02:36.000Z',
61+
proofKind: 'bitcoin_message_signature',
62+
paymentAddress: 'tb1q8whqdsl7853r8fft6sj7dnh4tcvm2xkhs7d8t2',
63+
authAddress: 'tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2',
64+
addressType: 'p2tr',
65+
message: 'Bitcode Bitcoin wallet authentication\nAddress: tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2\nPurpose: Authenticate Bitcode commercial profile',
66+
signature: 'signed-proof',
67+
persistence: 'local',
68+
}),
69+
);
70+
71+
render(<WalletSessionPersistenceBridge />);
72+
73+
await waitFor(() => {
74+
expect(global.fetch).toHaveBeenCalledWith(
75+
'/api/wallet/authenticate',
76+
expect.objectContaining({
77+
method: 'POST',
78+
headers: { 'Content-Type': 'application/json' },
79+
}),
80+
);
81+
expect(mockMutateUserData).toHaveBeenCalled();
82+
});
83+
84+
const persistedIdentity = readLocalBitcodeWalletIdentity();
85+
expect(persistedIdentity?.persistence).toBe('server');
86+
expect(persistedIdentity?.provider).toBe('leather');
87+
expect(persistedIdentity?.connectedAt).toBe('2026-05-14T14:08:42.562Z');
88+
});
89+
90+
it('does not call wallet persistence without a Supabase user session', async () => {
91+
getUser.mockResolvedValueOnce({ data: { user: null }, error: null });
92+
window.localStorage.setItem(
93+
BITCODE_LOCAL_WALLET_STORAGE_KEY,
94+
JSON.stringify({
95+
address: 'tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2',
96+
provider: 'leather',
97+
network: 'testnet',
98+
status: 'pending',
99+
connectedAt: '2026-05-14T14:02:36.000Z',
100+
proofKind: 'bitcoin_message_signature',
101+
message: 'Bitcode Bitcoin wallet authentication\nAddress: tb1p6x70u8ag7hkmgsve58lxhpgk5fhnanxp2vtuhvccv6n54f2m9mrsxe6wc2\nPurpose: Authenticate Bitcode commercial profile',
102+
signature: 'signed-proof',
103+
persistence: 'local',
104+
}),
105+
);
106+
107+
render(<WalletSessionPersistenceBridge />);
108+
109+
await waitFor(() => {
110+
expect(getUser).toHaveBeenCalled();
111+
});
112+
expect(global.fetch).not.toHaveBeenCalled();
113+
});
114+
});

0 commit comments

Comments
 (0)