release: prepare v0.9.7

This commit is contained in:
BigBodyCobain
2026-05-01 22:56:50 -06:00
parent ea457f27da
commit 28b3bd5ebf
670 changed files with 187059 additions and 14005 deletions
+459
View File
@@ -0,0 +1,459 @@
import type { Contact } from '@/mesh/meshIdentity';
import type {
ContactRootDistributionState,
ContactRootWitnessProvenanceState,
ContactTrustRecommendedAction,
ContactTrustSeverity,
ContactTrustSummary,
} from '@/mesh/contactTrustTypes';
function normalizeState(value: string | undefined): string {
const state = String(value || '').trim();
if (
state === 'unpinned' ||
state === 'tofu_pinned' ||
state === 'invite_pinned' ||
state === 'sas_verified' ||
state === 'mismatch' ||
state === 'continuity_broken'
) {
return state;
}
return 'unpinned';
}
function normalizeExistingTrustSummary(
summary: Partial<ContactTrustSummary> | null | undefined,
): ContactTrustSummary | null {
if (!summary || typeof summary !== 'object') return null;
const severity = String(summary.severity || '').trim() as ContactTrustSeverity;
const recommendedAction = String(
summary.recommendedAction || '',
).trim() as ContactTrustRecommendedAction;
if (
!['danger', 'warn', 'good', 'info'].includes(severity) ||
!['none', 'import_invite', 'verify_sas', 'show_sas', 'reverify'].includes(
recommendedAction,
)
) {
return null;
}
const label = String(summary.label || '').trim();
const detail = String(summary.detail || '').trim();
if (!label || !detail) return null;
const rootDistributionState = String(
summary.rootDistributionState || 'none',
).trim() as ContactRootDistributionState;
if (
![
'none',
'internal_only',
'single_witness',
'quorum_witnessed',
'witness_policy_not_met',
].includes(rootDistributionState)
) {
return null;
}
const rootWitnessThreshold = Number(summary.rootWitnessThreshold || 0);
const rootWitnessCount = Number(summary.rootWitnessCount || 0);
const rootWitnessQuorumMet = Boolean(
summary.rootWitnessQuorumMet ||
rootDistributionState === 'quorum_witnessed' ||
rootDistributionState === 'single_witness',
);
const rootWitnessDomainCount = Number(
summary.rootWitnessDomainCount ||
(rootDistributionState === 'quorum_witnessed' || rootDistributionState === 'single_witness' ? 1 : 0),
);
const rootWitnessIndependentQuorumMet = Boolean(
summary.rootWitnessIndependentQuorumMet ||
(rootWitnessThreshold > 1 && rootWitnessDomainCount >= rootWitnessThreshold),
);
const rootWitnessProvenanceState = String(
summary.rootWitnessProvenanceState ||
(rootDistributionState === 'quorum_witnessed'
? rootWitnessIndependentQuorumMet
? 'independent_quorum'
: 'local_quorum'
: rootDistributionState),
).trim() as ContactRootWitnessProvenanceState;
if (
![
'none',
'internal_only',
'single_witness',
'witness_policy_not_met',
'local_quorum',
'independent_quorum',
].includes(rootWitnessProvenanceState)
) {
return null;
}
return {
state: normalizeState(summary.state),
label,
severity,
detail,
verifiedFirstContact: Boolean(summary.verifiedFirstContact),
recommendedAction,
legacyLookup: Boolean(summary.legacyLookup),
inviteAttested: Boolean(summary.inviteAttested),
rootAttested: Boolean(summary.rootAttested),
rootWitnessed: Boolean(summary.rootWitnessed),
rootDistributionState,
rootWitnessPolicyFingerprint: String(summary.rootWitnessPolicyFingerprint || '').trim().toLowerCase(),
rootWitnessCount,
rootWitnessThreshold,
rootWitnessQuorumMet,
rootWitnessProvenanceState,
rootWitnessDomainCount,
rootWitnessIndependentQuorumMet,
rootManifestGeneration: Number(summary.rootManifestGeneration || 0),
rootRotationProven: Boolean(summary.rootRotationProven),
rootMismatch: Boolean(summary.rootMismatch),
registryMismatch: Boolean(summary.registryMismatch),
transparencyConflict: Boolean(summary.transparencyConflict),
};
}
function deriveRootWitnessProvenanceState(args: {
rootAttested: boolean;
rootWitnessed: boolean;
rootWitnessQuorumMet: boolean;
rootWitnessThreshold: number;
rootWitnessIndependentQuorumMet: boolean;
}): ContactRootWitnessProvenanceState {
const {
rootAttested,
rootWitnessed,
rootWitnessQuorumMet,
rootWitnessThreshold,
rootWitnessIndependentQuorumMet,
} = args;
if (!rootAttested) return 'none';
if (!rootWitnessed) return 'internal_only';
if (!rootWitnessQuorumMet) return 'witness_policy_not_met';
if (rootWitnessThreshold <= 1) return 'single_witness';
return rootWitnessIndependentQuorumMet ? 'independent_quorum' : 'local_quorum';
}
export function rootWitnessBadgeLabel(
summary: Pick<ContactTrustSummary, 'rootAttested' | 'rootWitnessProvenanceState' | 'rootWitnessed'> | null | undefined,
): string {
if (!summary?.rootAttested) return 'Root';
switch (summary.rootWitnessProvenanceState) {
case 'independent_quorum':
return 'Independent quorum root';
case 'local_quorum':
return 'Local quorum root';
case 'single_witness':
return 'Single-witness root';
case 'witness_policy_not_met':
return 'Witness-policy root';
default:
return summary.rootWitnessed ? 'Witnessed root' : 'Root';
}
}
export function rootWitnessContinuityLabel(
summary: Pick<ContactTrustSummary, 'rootAttested' | 'rootWitnessProvenanceState' | 'rootWitnessed'> | null | undefined,
): string {
if (!summary?.rootAttested) return 'Stable root continuity';
switch (summary.rootWitnessProvenanceState) {
case 'independent_quorum':
return 'Independent-quorum stable root continuity';
case 'local_quorum':
return 'Local-quorum stable root continuity';
case 'single_witness':
return 'Single-witness stable root continuity';
case 'witness_policy_not_met':
return 'Witness-policy-not-met stable root continuity';
default:
return summary.rootWitnessed ? 'Witnessed stable root continuity' : 'Stable root continuity';
}
}
export function rootWitnessIdentityLabel(
summary: Pick<ContactTrustSummary, 'rootAttested' | 'rootWitnessProvenanceState' | 'rootWitnessed'> | null | undefined,
): string {
if (!summary?.rootAttested) return 'stable root identity';
switch (summary.rootWitnessProvenanceState) {
case 'independent_quorum':
return 'independently quorum-witnessed stable root identity';
case 'local_quorum':
return 'locally quorum-witnessed stable root identity';
case 'single_witness':
return 'single-witness stable root identity';
case 'witness_policy_not_met':
return 'witnessed stable root identity';
default:
return summary.rootWitnessed ? 'witnessed stable root identity' : 'stable root identity';
}
}
function deriveTrustSummary(contact?: Partial<Contact> | null): ContactTrustSummary | null {
if (!contact) return null;
const transparencyConflict = Boolean(contact.remotePrekeyTransparencyConflict);
const registryMismatch = Boolean(contact.verify_mismatch);
const inviteAttested = Boolean(contact.invitePinnedTrustFingerprint || contact.invitePinnedAt);
const rootAttested = Boolean(contact.invitePinnedRootFingerprint || contact.remotePrekeyRootFingerprint);
const rootWitnessed = Boolean(
contact.invitePinnedRootManifestFingerprint ||
contact.remotePrekeyRootManifestFingerprint ||
contact.remotePrekeyObservedRootManifestFingerprint,
);
const rootMismatch = Boolean(contact.remotePrekeyRootMismatch);
const rootWitnessPolicyFingerprint = String(
rootMismatch
? contact.remotePrekeyObservedRootWitnessPolicyFingerprint || ''
: contact.remotePrekeyRootWitnessPolicyFingerprint ||
contact.invitePinnedRootWitnessPolicyFingerprint ||
'',
)
.trim()
.toLowerCase();
const rawRootWitnessThreshold = Number(
rootMismatch
? contact.remotePrekeyObservedRootWitnessThreshold || 0
: contact.remotePrekeyRootWitnessThreshold || contact.invitePinnedRootWitnessThreshold || 0,
);
const rawRootWitnessCount = Number(
rootMismatch
? contact.remotePrekeyObservedRootWitnessCount || 0
: contact.remotePrekeyRootWitnessCount || contact.invitePinnedRootWitnessCount || 0,
);
const rawRootWitnessDomainCount = Number(
rootMismatch
? contact.remotePrekeyObservedRootWitnessDomainCount || 0
: contact.remotePrekeyRootWitnessDomainCount || contact.invitePinnedRootWitnessDomainCount || 0,
);
const rootWitnessThreshold = rootWitnessed
? rawRootWitnessThreshold > 0
? rawRootWitnessThreshold
: 1
: 0;
const rootWitnessCount = rootWitnessed
? rawRootWitnessCount > 0
? rawRootWitnessCount
: 1
: 0;
const rootWitnessDomainCount = rootWitnessed
? rawRootWitnessDomainCount > 0
? rawRootWitnessDomainCount
: 1
: 0;
const rootWitnessQuorumMet = rootWitnessed
? rootWitnessThreshold <= 1 || rootWitnessCount >= rootWitnessThreshold
: false;
const rootWitnessIndependentQuorumMet = rootWitnessed
? rootWitnessThreshold <= 1 || rootWitnessDomainCount >= rootWitnessThreshold
: false;
const rootDistributionState: ContactRootDistributionState = !rootAttested
? 'none'
: !rootWitnessed
? 'internal_only'
: !rootWitnessQuorumMet
? 'witness_policy_not_met'
: rootWitnessThreshold <= 1
? 'single_witness'
: 'quorum_witnessed';
const rootWitnessProvenanceState = deriveRootWitnessProvenanceState({
rootAttested,
rootWitnessed,
rootWitnessQuorumMet,
rootWitnessThreshold,
rootWitnessIndependentQuorumMet,
});
const rootManifestGeneration = Number(
rootMismatch
? contact.remotePrekeyObservedRootManifestGeneration ||
contact.remotePrekeyRootManifestGeneration ||
contact.invitePinnedRootManifestGeneration ||
0
: contact.remotePrekeyRootManifestGeneration ||
contact.invitePinnedRootManifestGeneration ||
0,
);
const rootRotationProven =
rootManifestGeneration > 0 &&
(rootManifestGeneration <= 1 ||
Boolean(
rootMismatch
? contact.remotePrekeyObservedRootRotationProven
: contact.remotePrekeyRootRotationProven || contact.invitePinnedRootRotationProven,
));
const rootRotationUnproven =
rootWitnessed && rootManifestGeneration > 1 && !rootRotationProven;
const rootDistributionUpgradeNeeded =
rootAttested &&
['internal_only', 'single_witness', 'witness_policy_not_met'].includes(rootDistributionState);
let state = normalizeState(contact.trust_level);
if (contact.remotePrekeyMismatch) {
state =
state === 'invite_pinned' || state === 'sas_verified' || inviteAttested
? 'continuity_broken'
: 'mismatch';
} else if (rootMismatch) {
state =
state === 'invite_pinned' || state === 'sas_verified' || inviteAttested || rootAttested
? 'continuity_broken'
: 'mismatch';
} else if (!contact.trust_level && (contact.remotePrekeyFingerprint || contact.remotePrekeyPinnedAt)) {
state = inviteAttested ? 'invite_pinned' : 'tofu_pinned';
}
const legacyLookup =
String(contact.remotePrekeyLookupMode || '').trim().toLowerCase() === 'legacy_agent_id';
let label = 'UNVERIFIED';
let severity: ContactTrustSeverity = 'warn';
let detail =
'No trusted first-contact anchor. Import a signed invite before secure first contact.';
let recommendedAction: ContactTrustRecommendedAction = 'import_invite';
if (state === 'continuity_broken') {
label = 'CONTINUITY BROKEN';
severity = 'danger';
detail =
'Pinned trust anchor changed. Re-verify SAS or replace the invite before private use.';
recommendedAction = 'reverify';
} else if (state === 'mismatch' || (state === 'unpinned' && contact.remotePrekeyMismatch)) {
label = 'REVERIFY';
severity = 'danger';
detail = 'Observed prekey identity changed. Compare SAS before trusting the new key.';
recommendedAction = 'reverify';
} else if (
state === 'tofu_pinned' ||
(!contact.trust_level && (contact.remotePrekeyFingerprint || contact.remotePrekeyPinnedAt))
) {
label = 'TOFU PINNED';
detail = rootAttested
? rootWitnessed
? rootRotationUnproven
? 'Current prekey is seen under one witnessed stable root, but that root rotation lacks previous-root proof. Replace the signed invite before treating this root as continuous.'
: rootWitnessProvenanceState === 'independent_quorum'
? 'Current prekey is seen under one independently quorum-witnessed stable root, but first contact is still TOFU-only. Verify SAS before sensitive use.'
: rootWitnessProvenanceState === 'local_quorum'
? 'Current prekey is seen under one locally quorum-witnessed stable root, but first contact is still TOFU-only. Verify SAS before sensitive use.'
: rootDistributionState === 'single_witness'
? 'Current prekey is seen under one single-witness stable root, but first contact is still TOFU-only. Re-import a current signed invite if you want stronger quorum witness provenance.'
: 'Current prekey is seen under a witnessed stable root, but the current witness policy is not satisfied. Replace or re-import the signed invite before treating this root as strong first-contact provenance.'
: 'Current prekey is seen under one stable root, but first contact is still TOFU-only. Verify SAS before sensitive use.'
: 'First contact is pinned on first sight only. Verify SAS before sensitive use.';
recommendedAction = rootRotationUnproven ? 'import_invite' : 'verify_sas';
} else if (state === 'invite_pinned' || inviteAttested) {
label = 'INVITE PINNED';
detail = rootAttested
? rootWitnessed
? rootRotationUnproven
? 'First contact is anchored to an imported signed invite and a witnessed stable root identity, but its current root rotation lacks previous-root proof. Replace the signed invite before private use.'
: rootWitnessProvenanceState === 'independent_quorum'
? 'First contact is anchored to an imported signed invite and an independently quorum-witnessed stable root identity. SAS is optional but recommended for continuity.'
: rootWitnessProvenanceState === 'local_quorum'
? 'First contact is anchored to an imported signed invite and a locally quorum-witnessed stable root identity. SAS is optional but recommended for continuity.'
: rootDistributionState === 'single_witness'
? 'First contact is anchored to an imported signed invite and a single-witness stable root identity. Re-import a current signed invite if you want stronger quorum witness provenance.'
: 'First contact is anchored to an imported signed invite and a witnessed stable root identity, but the current witness policy is not satisfied. Replace the signed invite before private use.'
: 'First contact is anchored to an imported signed invite and a stable root identity, but root distribution is still internal-only. Re-import a current signed invite to refresh witnessed root distribution.'
: 'First contact is anchored to an imported signed invite. SAS is optional but recommended for continuity.';
recommendedAction = rootDistributionUpgradeNeeded || rootRotationUnproven ? 'import_invite' : 'show_sas';
} else if (state === 'sas_verified') {
label = 'SAS VERIFIED';
severity = 'good';
detail = rootAttested
? rootWitnessed
? rootRotationUnproven
? 'This contact was SAS confirmed on the current pinned fingerprint, but its current witnessed root rotation lacks previous-root proof.'
: rootWitnessProvenanceState === 'independent_quorum'
? 'This contact was SAS confirmed on the current pinned fingerprint and independently quorum-witnessed stable root identity.'
: rootWitnessProvenanceState === 'local_quorum'
? 'This contact was SAS confirmed on the current pinned fingerprint and locally quorum-witnessed stable root identity.'
: rootDistributionState === 'single_witness'
? 'This contact was SAS confirmed on the current pinned fingerprint and single-witness stable root identity. Re-import a current signed invite if you want stronger quorum witness provenance.'
: 'This contact was SAS confirmed on the current pinned fingerprint, but the current witnessed root does not satisfy its witness policy.'
: 'This contact was SAS confirmed on the current pinned fingerprint and stable root identity, but root distribution is still internal-only.'
: 'This contact was confirmed with a shared SAS phrase on the current pinned fingerprint.';
recommendedAction = rootDistributionUpgradeNeeded || rootRotationUnproven ? 'import_invite' : 'show_sas';
}
if (rootMismatch && state !== 'continuity_broken' && state !== 'mismatch') {
state = inviteAttested || rootAttested ? 'continuity_broken' : 'mismatch';
}
if (rootMismatch) {
label = state === 'continuity_broken' ? 'CONTINUITY BROKEN' : 'REVERIFY';
severity = 'danger';
detail =
state === 'continuity_broken'
? rootWitnessProvenanceState === 'independent_quorum'
? 'Pinned independently quorum-witnessed stable root identity changed. Replace the signed invite or re-verify SAS before private use.'
: rootWitnessProvenanceState === 'local_quorum'
? 'Pinned locally quorum-witnessed stable root identity changed. Replace the signed invite or re-verify SAS before private use.'
: rootDistributionState === 'single_witness'
? 'Pinned single-witness stable root identity changed. Replace the signed invite or re-verify SAS before private use.'
: rootWitnessed
? 'Pinned stable root identity changed and its witness policy is not satisfied. Replace the signed invite or re-verify SAS before private use.'
: 'Pinned stable root identity changed. Replace the signed invite or re-verify SAS before private use.'
: rootWitnessProvenanceState === 'independent_quorum'
? 'Observed independently quorum-witnessed stable root identity changed. Replace the invite or compare SAS before trusting the new key.'
: rootWitnessProvenanceState === 'local_quorum'
? 'Observed locally quorum-witnessed stable root identity changed. Replace the invite or compare SAS before trusting the new key.'
: rootDistributionState === 'single_witness'
? 'Observed single-witness stable root identity changed. Replace the invite or compare SAS before trusting the new key.'
: rootWitnessed
? 'Observed stable root identity changed and its witness policy is not satisfied. Replace the invite before trusting the new key.'
: 'Observed stable root identity changed. Replace the invite or compare SAS before trusting the new key.';
recommendedAction = 'reverify';
}
if (rootRotationUnproven && state !== 'continuity_broken' && state !== 'mismatch') {
recommendedAction = 'import_invite';
}
if (transparencyConflict) {
detail =
'Prekey transparency history conflicted. Trust stays degraded until you explicitly acknowledge the changed fingerprint.';
}
if (legacyLookup && state !== 'mismatch' && state !== 'continuity_broken' && !transparencyConflict) {
detail = `${detail} This contact still bootstraps through legacy direct agent ID lookup. Import or re-import a signed invite to avoid stable-ID lookup before removal.`;
recommendedAction = 'import_invite';
}
return {
state,
label,
severity,
detail,
verifiedFirstContact:
(state === 'invite_pinned' || state === 'sas_verified') &&
!rootRotationUnproven &&
rootDistributionState !== 'witness_policy_not_met',
recommendedAction,
legacyLookup,
inviteAttested,
rootAttested,
rootWitnessed,
rootDistributionState,
rootWitnessPolicyFingerprint,
rootWitnessCount,
rootWitnessThreshold,
rootWitnessQuorumMet,
rootWitnessProvenanceState,
rootWitnessDomainCount,
rootWitnessIndependentQuorumMet,
rootManifestGeneration,
rootRotationProven,
rootMismatch,
registryMismatch,
transparencyConflict,
};
}
export function getContactTrustSummary(
contact?: Partial<Contact> | null,
): ContactTrustSummary | null {
const existing = normalizeExistingTrustSummary(
contact?.trustSummary as Partial<ContactTrustSummary> | null | undefined,
);
if (existing) return existing;
return deriveTrustSummary(contact);
}
+47
View File
@@ -0,0 +1,47 @@
export type ContactTrustSeverity = 'danger' | 'warn' | 'good' | 'info';
export type ContactRootDistributionState =
| 'none'
| 'internal_only'
| 'single_witness'
| 'quorum_witnessed'
| 'witness_policy_not_met';
export type ContactRootWitnessProvenanceState =
| 'none'
| 'internal_only'
| 'single_witness'
| 'witness_policy_not_met'
| 'local_quorum'
| 'independent_quorum';
export type ContactTrustRecommendedAction =
| 'none'
| 'import_invite'
| 'verify_sas'
| 'show_sas'
| 'reverify';
export interface ContactTrustSummary {
state: string;
label: string;
severity: ContactTrustSeverity;
detail: string;
verifiedFirstContact: boolean;
recommendedAction: ContactTrustRecommendedAction;
legacyLookup: boolean;
inviteAttested: boolean;
rootAttested?: boolean;
rootWitnessed?: boolean;
rootDistributionState?: ContactRootDistributionState;
rootWitnessPolicyFingerprint?: string;
rootWitnessCount?: number;
rootWitnessThreshold?: number;
rootWitnessQuorumMet?: boolean;
rootWitnessProvenanceState?: ContactRootWitnessProvenanceState;
rootWitnessDomainCount?: number;
rootWitnessIndependentQuorumMet?: boolean;
rootManifestGeneration?: number;
rootRotationProven?: boolean;
rootMismatch?: boolean;
registryMismatch: boolean;
transparencyConflict: boolean;
}
@@ -21,6 +21,7 @@ export interface InfonetBootstrapSnapshot {
sync_peer_count?: number;
push_peer_count?: number;
operator_peer_count?: number;
default_sync_peer_count?: number;
last_bootstrap_error?: string;
}
@@ -70,6 +71,16 @@ export interface InfonetNodeStatusSnapshot {
private_lane_tier?: string;
}
export interface NodeSettingsSnapshot {
enabled?: boolean;
timemachine_enabled?: boolean;
updated_at?: number;
node_mode?: string;
node_enabled?: boolean;
}
export const DEFAULT_INFONET_SEED_URL = 'https://node.shadowbroker.info';
const CACHE_TTL_MS = 15000;
type CacheEntry<T> = {
@@ -106,6 +117,12 @@ function loadInfonetNodeStatus(): Promise<InfonetNodeStatusSnapshot> {
});
}
function loadNodeSettings(): Promise<NodeSettingsSnapshot> {
return controlPlaneJson<NodeSettingsSnapshot>('/api/settings/node', {
requireAdminSession: false,
});
}
async function resolveCached<T>(
cache: CacheEntry<T>,
loader: () => Promise<T>,
@@ -188,3 +205,18 @@ export async function fetchInfonetNodeStatusSnapshot(
force,
);
}
export async function fetchNodeSettingsSnapshot(): Promise<NodeSettingsSnapshot> {
return loadNodeSettings();
}
export async function setInfonetNodeEnabled(enabled: boolean): Promise<NodeSettingsSnapshot> {
const result = await controlPlaneJson<NodeSettingsSnapshot>('/api/settings/node', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
requireAdminSession: false,
});
invalidateInfonetNodeStatusCache();
return result;
}
+132
View File
@@ -0,0 +1,132 @@
import { controlPlaneJson } from '@/lib/controlPlane';
import { hasLocalControlBridge } from '@/lib/localControlTransport';
import { getGateSessionStreamAccessHeaders } from '@/mesh/gateSessionStream';
const GATE_ACCESS_PROOF_BROWSER_TTL_MS = 52_000;
const GATE_ACCESS_PROOF_NATIVE_TTL_MS = 35_000;
const GATE_ACCESS_PROOF_EXTENDED_BROWSER_MAX_AGE_MS = 58_000;
const GATE_ACCESS_PROOF_EXTENDED_NATIVE_MAX_AGE_MS = 58_000;
export type GateAccessHeaderMode = 'default' | 'wait' | 'session_stream';
export const gateAccessHeaderCache = new Map<
string,
{ headers: Record<string, string>; expiresAt: number; proofTsMs: number }
>();
const gateAccessHeaderInflight = new Map<string, Promise<Record<string, string> | undefined>>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function gateAccessProofTtlMs(): number {
return hasLocalControlBridge()
? GATE_ACCESS_PROOF_NATIVE_TTL_MS
: GATE_ACCESS_PROOF_BROWSER_TTL_MS;
}
function gateAccessProofExtendedMaxAgeMs(): number {
return hasLocalControlBridge()
? GATE_ACCESS_PROOF_EXTENDED_NATIVE_MAX_AGE_MS
: GATE_ACCESS_PROOF_EXTENDED_BROWSER_MAX_AGE_MS;
}
function gateAccessHeaderReusableUntilMs(entry: {
expiresAt: number;
proofTsMs: number;
}, mode: GateAccessHeaderMode): number {
if (mode === 'default' || !Number.isFinite(entry.proofTsMs) || entry.proofTsMs <= 0) {
return entry.expiresAt;
}
return Math.max(entry.expiresAt, entry.proofTsMs + gateAccessProofExtendedMaxAgeMs());
}
export function pruneExpiredGateAccessHeaders(now: number = Date.now()): void {
for (const [gateId, entry] of gateAccessHeaderCache.entries()) {
if (gateAccessHeaderReusableUntilMs(entry, 'wait') <= now) {
gateAccessHeaderCache.delete(gateId);
}
}
}
export function invalidateGateAccessHeaders(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
gateAccessHeaderCache.clear();
gateAccessHeaderInflight.clear();
return;
}
gateAccessHeaderCache.delete(normalized);
gateAccessHeaderInflight.delete(normalized);
}
export async function buildGateAccessHeaders(
gateId: string,
options: { mode?: GateAccessHeaderMode } = {},
): Promise<Record<string, string> | undefined> {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate) return undefined;
const mode =
options.mode === 'wait' || options.mode === 'session_stream'
? options.mode
: 'default';
pruneExpiredGateAccessHeaders();
const cached = gateAccessHeaderCache.get(normalizedGate);
if (cached && gateAccessHeaderReusableUntilMs(cached, mode) > Date.now()) {
return cached.headers;
}
if (mode === 'session_stream') {
const streamHeaders = getGateSessionStreamAccessHeaders(normalizedGate);
if (streamHeaders) {
const proofTsMs = Math.max(
0,
Number.parseInt(String(streamHeaders['X-Wormhole-Gate-Ts'] || '0'), 10) * 1000,
);
gateAccessHeaderCache.set(normalizedGate, {
headers: streamHeaders,
expiresAt: Date.now() + gateAccessProofTtlMs(),
proofTsMs,
});
return streamHeaders;
}
}
const inflight = gateAccessHeaderInflight.get(normalizedGate);
if (inflight) {
return inflight;
}
const pending = (async () => {
try {
const proof = await controlPlaneJson<{ node_id?: string; ts?: number; proof?: string }>(
'/api/wormhole/gate/proof',
{
requireAdminSession: false,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gate_id: normalizedGate }),
},
);
const nodeId = String(proof.node_id || '').trim();
const gateProof = String(proof.proof || '').trim();
const gateTs = String(proof.ts || '').trim();
if (!nodeId || !gateProof || !gateTs) return undefined;
const proofTsMs = Math.max(0, Number(gateTs || 0)) * 1000;
const headers = {
'X-Wormhole-Node-Id': nodeId,
'X-Wormhole-Gate-Proof': gateProof,
'X-Wormhole-Gate-Ts': gateTs,
};
gateAccessHeaderCache.set(normalizedGate, {
headers,
expiresAt: Date.now() + gateAccessProofTtlMs(),
proofTsMs,
});
return headers;
} catch {
return undefined;
} finally {
gateAccessHeaderInflight.delete(normalizedGate);
}
})();
gateAccessHeaderInflight.set(normalizedGate, pending);
return pending;
}
+109
View File
@@ -0,0 +1,109 @@
import { API_BASE } from '@/lib/api';
import { hasLocalControlBridge } from '@/lib/localControlTransport';
export interface GateCatalogEntry {
gate_id: string;
display_name?: string;
description?: string;
message_count?: number;
fixed?: boolean;
rules?: {
min_overall_rep?: number;
};
}
export interface GateDetailSnapshot {
ok?: boolean;
gate_id: string;
display_name?: string;
description?: string;
welcome?: string;
creator_node_id?: string;
message_count?: number;
fixed?: boolean;
rules?: {
min_overall_rep?: number;
};
detail?: string;
}
const GATE_CATALOG_BROWSER_TTL_MS = 18_000;
const GATE_CATALOG_NATIVE_TTL_MS = 6_000;
const GATE_DETAIL_BROWSER_TTL_MS = 15_000;
const GATE_DETAIL_NATIVE_TTL_MS = 5_000;
let gateCatalogCache: { value: GateCatalogEntry[]; expiresAt: number } | null = null;
const gateDetailCache = new Map<string, { value: GateDetailSnapshot; expiresAt: number }>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function gateCatalogTtlMs(): number {
return hasLocalControlBridge()
? GATE_CATALOG_NATIVE_TTL_MS
: GATE_CATALOG_BROWSER_TTL_MS;
}
function gateDetailTtlMs(): number {
return hasLocalControlBridge()
? GATE_DETAIL_NATIVE_TTL_MS
: GATE_DETAIL_BROWSER_TTL_MS;
}
export function invalidateGateCatalogSnapshot(): void {
gateCatalogCache = null;
}
export function invalidateGateDetailSnapshot(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
gateDetailCache.clear();
return;
}
gateDetailCache.delete(normalized);
}
export async function fetchGateCatalogSnapshot(
options: { force?: boolean } = {},
): Promise<GateCatalogEntry[]> {
if (!options.force && gateCatalogCache && gateCatalogCache.expiresAt > Date.now()) {
return gateCatalogCache.value;
}
try {
const response = await fetch(`${API_BASE}/api/mesh/gate/list`);
const data = await response.json().catch(() => ({}));
const gates = Array.isArray(data?.gates) ? (data.gates as GateCatalogEntry[]) : [];
gateCatalogCache = {
value: gates,
expiresAt: Date.now() + gateCatalogTtlMs(),
};
return gates;
} catch {
return gateCatalogCache?.value || [];
}
}
export async function fetchGateDetailSnapshot(
gateId: string,
options: { force?: boolean } = {},
): Promise<GateDetailSnapshot> {
const normalizedGate = normalizeGateId(gateId);
const cached = gateDetailCache.get(normalizedGate);
if (!options.force && cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const response = await fetch(`${API_BASE}/api/mesh/gate/${encodeURIComponent(normalizedGate)}`);
const data = await response.json().catch(() => ({}));
const detail = {
...(data && typeof data === 'object' ? data : {}),
gate_id: normalizeGateId(String((data as { gate_id?: string } | null)?.gate_id || normalizedGate)),
} as GateDetailSnapshot;
if (response.ok && detail.ok !== false) {
gateDetailCache.set(normalizedGate, {
value: detail,
expiresAt: Date.now() + gateDetailTtlMs(),
});
}
return detail;
}
+379
View File
@@ -0,0 +1,379 @@
import { getNodeIdentity, getWormholeIdentityDescriptor } from '@/mesh/meshIdentity';
const KEY_SESSION_MODE = 'sb_mesh_session_mode';
const KEY_GATE_COMPAT_TELEMETRY = 'sb_gate_compat_telemetry_v1';
const GATE_COMPAT_TELEMETRY_EVENT = 'sb:gate-compat-telemetry';
const MAX_RECENT_EVENTS = 12;
const MAX_RECENT_GATES = 4;
export type GateCompatTelemetryAction = 'compose' | 'post' | 'decrypt';
export type GateCompatTelemetryKind = 'required' | 'used';
type StoredGateCompatReasonBucket = {
required_count?: number;
used_count?: number;
last_at?: number;
actions?: Partial<Record<GateCompatTelemetryAction, number>>;
recent_gates?: string[];
};
type StoredGateCompatEvent = {
gate_id?: string;
action?: GateCompatTelemetryAction;
reason?: string;
kind?: GateCompatTelemetryKind;
at?: number;
};
type StoredGateCompatScope = {
total_required?: number;
total_used?: number;
last_at?: number;
by_reason?: Record<string, StoredGateCompatReasonBucket>;
recent?: StoredGateCompatEvent[];
};
type StoredGateCompatTelemetry = Record<string, StoredGateCompatScope>;
export interface GateCompatTelemetryReasonSummary {
reason: string;
label: string;
requiredCount: number;
usedCount: number;
lastAt: number;
actions: Partial<Record<GateCompatTelemetryAction, number>>;
recentGates: string[];
}
export interface GateCompatTelemetryRecentEvent {
gateId: string;
action: GateCompatTelemetryAction;
reason: string;
label: string;
kind: GateCompatTelemetryKind;
at: number;
}
export interface GateCompatTelemetrySnapshot {
totalRequired: number;
totalUsed: number;
lastAt: number;
reasons: GateCompatTelemetryReasonSummary[];
recent: GateCompatTelemetryRecentEvent[];
}
export interface GateCompatTelemetryTopReason {
reason: string;
label: string;
requiredCount: number;
usedCount: number;
lastAt: number;
recentGates: string[];
}
function compatTelemetryStorageSelection(): {
storage: Storage | null;
mode: 'persistent' | 'session';
} {
if (typeof window === 'undefined') {
return { storage: null, mode: 'session' };
}
try {
const persistent = window.localStorage;
const session = window.sessionStorage;
return persistent.getItem(KEY_SESSION_MODE) !== 'false'
? { storage: session, mode: 'session' }
: { storage: persistent, mode: 'persistent' };
} catch {
try {
return { storage: window.sessionStorage, mode: 'session' };
} catch {
return { storage: null, mode: 'session' };
}
}
}
function compatTelemetryStorage(): Storage | null {
return compatTelemetryStorageSelection().storage;
}
function compatTelemetryScope(): string {
const wormholeDescriptor = getWormholeIdentityDescriptor();
const nodeIdentity = getNodeIdentity();
const scopeId = String(wormholeDescriptor?.nodeId || nodeIdentity?.nodeId || 'default')
.trim()
.toLowerCase();
const { mode } = compatTelemetryStorageSelection();
return `${mode}:${scopeId || 'default'}`;
}
function safeRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}
function safeInt(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
export function normalizeGateCompatReason(reason: string): string {
return String(reason || '').trim().toLowerCase() || 'browser_local_gate_crypto_unavailable';
}
export function describeGateCompatReason(reason: string, gateId: string = ''): string {
const normalizedGate = normalizeGateId(gateId);
const detail = normalizeGateCompatReason(reason);
if (detail === 'browser_runtime_unavailable') {
return 'This runtime cannot host local gate crypto.';
}
if (detail === 'browser_local_gate_crypto_unavailable') {
return 'Local gate crypto failed on this device.';
}
if (detail === 'browser_gate_worker_unavailable') {
return 'This runtime cannot use the local gate worker.';
}
if (detail === 'browser_gate_webcrypto_unavailable') {
return 'This runtime lacks the WebCrypto features required for local gate crypto.';
}
if (detail === 'browser_gate_indexeddb_unavailable') {
return 'This runtime cannot persist local gate state.';
}
if (detail === 'browser_gate_storage_unavailable') {
return 'Secure local gate storage failed in this browser.';
}
if (detail === 'browser_gate_wasm_unavailable') {
return 'Local gate crypto could not load on this device.';
}
if (detail.startsWith('browser_gate_state_resync_required:')) {
return normalizedGate
? `Local ${normalizedGate} state needs a resync on this device.`
: 'Local gate state needs a resync on this device.';
}
if (
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
detail === 'browser_gate_state_active_member_missing'
) {
return 'Local gate state is incomplete on this device.';
}
if (detail === 'worker_gate_wrap_key_missing') {
return 'Secure local gate storage is unavailable in this browser.';
}
if (detail === 'gate_mls_decrypt_failed') {
return 'Local gate decrypt failed on this device.';
}
if (detail === 'gate_sign_failed') {
return 'Local gate signing failed on this device.';
}
return 'Local gate crypto failed on this device.';
}
function readStoredTelemetry(): StoredGateCompatTelemetry {
const storage = compatTelemetryStorage();
if (!storage) return {};
try {
const raw = storage.getItem(KEY_GATE_COMPAT_TELEMETRY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as StoredGateCompatTelemetry) : {};
} catch {
return {};
}
}
function writeStoredTelemetry(next: StoredGateCompatTelemetry): void {
const storage = compatTelemetryStorage();
if (!storage) return;
try {
storage.setItem(KEY_GATE_COMPAT_TELEMETRY, JSON.stringify(next));
} catch {
/* ignore */
}
}
function dispatchTelemetryUpdate(snapshot: GateCompatTelemetrySnapshot): void {
if (typeof window === 'undefined') return;
try {
window.dispatchEvent(
new CustomEvent(GATE_COMPAT_TELEMETRY_EVENT, {
detail: snapshot,
}),
);
} catch {
/* ignore */
}
}
export function getGateCompatTelemetryEventName(): string {
return GATE_COMPAT_TELEMETRY_EVENT;
}
export function formatGateCompatSeenAt(timestamp: number): string {
if (!timestamp) return 'never';
try {
return new Date(timestamp).toISOString().replace('T', ' ').slice(0, 16) + 'Z';
} catch {
return 'never';
}
}
export function getGateCompatTelemetrySnapshot(): GateCompatTelemetrySnapshot {
const all = readStoredTelemetry();
const scope = compatTelemetryScope();
const current = safeRecord(all?.[scope]);
const reasonsRecord = safeRecord(current.by_reason);
const reasons = Object.entries(reasonsRecord)
.map(([reason, value]) => {
const bucket = safeRecord(value);
const actionsRecord = safeRecord(bucket.actions);
const actions: Partial<Record<GateCompatTelemetryAction, number>> = {};
(['compose', 'post', 'decrypt'] as GateCompatTelemetryAction[]).forEach((action) => {
const count = safeInt(actionsRecord[action]);
if (count > 0) actions[action] = count;
});
const recentGates = Array.isArray(bucket.recent_gates)
? bucket.recent_gates.map((item) => normalizeGateId(String(item || ''))).filter(Boolean)
: [];
return {
reason,
label: describeGateCompatReason(reason, recentGates[0] || ''),
requiredCount: safeInt(bucket.required_count),
usedCount: safeInt(bucket.used_count),
lastAt: safeInt(bucket.last_at),
actions,
recentGates,
};
})
.sort((a, b) => {
const aScore = a.requiredCount + a.usedCount;
const bScore = b.requiredCount + b.usedCount;
if (bScore !== aScore) return bScore - aScore;
return b.lastAt - a.lastAt;
});
const recent = Array.isArray(current.recent)
? current.recent
.map((value) => safeRecord(value))
.map((entry) => {
const gateId = normalizeGateId(String(entry.gate_id || ''));
const reason = normalizeGateCompatReason(String(entry.reason || ''));
const action = (String(entry.action || 'decrypt').trim().toLowerCase() ||
'decrypt') as GateCompatTelemetryAction;
const kind = (String(entry.kind || 'required').trim().toLowerCase() ||
'required') as GateCompatTelemetryKind;
const at = safeInt(entry.at);
return {
gateId,
action,
reason,
label: describeGateCompatReason(reason, gateId),
kind,
at,
};
})
.filter((entry) => entry.gateId)
: [];
return {
totalRequired: safeInt(current.total_required),
totalUsed: safeInt(current.total_used),
lastAt: safeInt(current.last_at),
reasons,
recent,
};
}
export function summarizeGateCompatTelemetry(
snapshot: GateCompatTelemetrySnapshot | null | undefined,
limit: number = 3,
): GateCompatTelemetryTopReason[] {
const current = snapshot || {
totalRequired: 0,
totalUsed: 0,
lastAt: 0,
reasons: [],
recent: [],
};
return current.reasons.slice(0, Math.max(1, limit)).map((item) => ({
reason: item.reason,
label: item.label,
requiredCount: item.requiredCount,
usedCount: item.usedCount,
lastAt: item.lastAt,
recentGates: item.recentGates,
}));
}
export function recordGateCompatTelemetry(input: {
gateId: string;
action: GateCompatTelemetryAction;
reason: string;
kind: GateCompatTelemetryKind;
at?: number;
}): void {
const storage = compatTelemetryStorage();
if (!storage) return;
const gateId = normalizeGateId(input.gateId);
if (!gateId) return;
const action = (String(input.action || 'decrypt').trim().toLowerCase() ||
'decrypt') as GateCompatTelemetryAction;
const reason = normalizeGateCompatReason(input.reason);
const kind = (String(input.kind || 'required').trim().toLowerCase() ||
'required') as GateCompatTelemetryKind;
const at = safeInt(input.at || Date.now()) || Date.now();
const all = readStoredTelemetry();
const scope = compatTelemetryScope();
const current: StoredGateCompatScope = all[scope] || {};
const byReason: Record<string, StoredGateCompatReasonBucket> = current.by_reason || {};
const bucket: StoredGateCompatReasonBucket = byReason[reason] || {};
const actions: Partial<Record<GateCompatTelemetryAction, number>> = bucket.actions || {};
const recentGates = Array.isArray(bucket.recent_gates)
? bucket.recent_gates.map((item) => normalizeGateId(String(item || ''))).filter(Boolean)
: [];
const nextRecentGates = [gateId, ...recentGates.filter((value) => value !== gateId)].slice(
0,
MAX_RECENT_GATES,
);
const nextRecent = [
{
gate_id: gateId,
action,
reason,
kind,
at,
},
...(Array.isArray(current.recent) ? current.recent : []),
].slice(0, MAX_RECENT_EVENTS);
const nextScope: StoredGateCompatScope = {
...current,
total_required: safeInt(current.total_required) + (kind === 'required' ? 1 : 0),
total_used: safeInt(current.total_used) + (kind === 'used' ? 1 : 0),
last_at: at,
by_reason: {
...byReason,
[reason]: {
...bucket,
required_count: safeInt(bucket.required_count) + (kind === 'required' ? 1 : 0),
used_count: safeInt(bucket.used_count) + (kind === 'used' ? 1 : 0),
last_at: at,
actions: {
...actions,
[action]: safeInt(actions[action]) + 1,
},
recent_gates: nextRecentGates,
},
},
recent: nextRecent,
};
writeStoredTelemetry({
...all,
[scope]: nextScope,
});
dispatchTelemetryUpdate(getGateCompatTelemetrySnapshot());
}
+14 -1
View File
@@ -7,6 +7,8 @@ export interface GateEnvelopeMessageLike {
nonce?: string;
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
decrypted_message?: string;
payload?: {
gate?: string;
@@ -14,6 +16,8 @@ export interface GateEnvelopeMessageLike {
nonce?: string;
sender_ref?: string;
format?: string;
gate_envelope?: string;
envelope_hash?: string;
};
}
@@ -39,7 +43,16 @@ export function gateEnvelopeDisplayText(message: GateEnvelopeMessageLike): strin
return String(message.message ?? '');
}
const decrypted = String(message.decrypted_message ?? '').trim();
return decrypted || 'ENCRYPTED GATE MESSAGE - KEY UNAVAILABLE';
if (decrypted) {
return decrypted;
}
const payload = message.payload;
const nestedEnvelope = payload && typeof payload === 'object' ? payload.gate_envelope : '';
const gateEnvelope = String((message.gate_envelope ?? nestedEnvelope ?? '') || '').trim();
if (!gateEnvelope) {
return 'Sealed message - durable gate envelope was not stored.';
}
return 'Sealed message - waiting for local gate decrypt.';
}
export function gateEnvelopeState(message: GateEnvelopeMessageLike): GateEnvelopeState {
+319
View File
@@ -0,0 +1,319 @@
import { API_BASE } from '@/lib/api';
import { hasLocalControlBridge } from '@/lib/localControlTransport';
import { buildGateAccessHeaders } from '@/mesh/gateAccessProof';
import type { GateAccessHeaderMode } from '@/mesh/gateAccessProof';
export interface GateMessageSnapshotRecord {
event_id: string;
event_type?: string;
node_id?: string;
message?: string;
ciphertext?: string;
epoch?: number;
nonce?: string;
sender_ref?: string;
format?: string;
gate?: string;
reply_to?: string;
gate_envelope?: string;
envelope_hash?: string;
payload?: {
gate?: string;
ciphertext?: string;
nonce?: string;
sender_ref?: string;
format?: string;
reply_to?: string;
gate_envelope?: string;
envelope_hash?: string;
};
timestamp: number;
ephemeral?: boolean;
system_seed?: boolean;
fixed_gate?: boolean;
}
export interface GateMessageSnapshotState {
messages: GateMessageSnapshotRecord[];
cursor: number;
changed?: boolean;
}
export const ACTIVE_GATE_ROOM_MESSAGE_LIMIT = 40;
const GATE_MESSAGES_BROWSER_TTL_MS = 10_000;
const GATE_MESSAGES_NATIVE_TTL_MS = 3_000;
const gateMessageCache = new Map<
string,
{ limit: number; value: GateMessageSnapshotRecord[]; expiresAt: number; cursor: number }
>();
const gateMessageFetchInflight = new Map<
string,
{ gateId: string; limit: number; promise: Promise<GateMessageSnapshotState> }
>();
const gateMessageWaitInflight = new Map<
string,
{
gateId: string;
afterCursor: number;
limit: number;
promise: Promise<GateMessageSnapshotState>;
}
>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function gateMessagesTtlMs(): number {
return hasLocalControlBridge()
? GATE_MESSAGES_NATIVE_TTL_MS
: GATE_MESSAGES_BROWSER_TTL_MS;
}
export function normalizeGateMessageSnapshotRecord(
message: GateMessageSnapshotRecord,
): GateMessageSnapshotRecord {
const payload =
message.payload && typeof message.payload === 'object'
? message.payload
: undefined;
return {
...message,
gate: String(message.gate ?? payload?.gate ?? ''),
ciphertext: String(message.ciphertext ?? payload?.ciphertext ?? ''),
nonce: String(message.nonce ?? payload?.nonce ?? ''),
sender_ref: String(message.sender_ref ?? payload?.sender_ref ?? ''),
format: String(message.format ?? payload?.format ?? ''),
reply_to: String(message.reply_to ?? payload?.reply_to ?? ''),
gate_envelope: String(message.gate_envelope ?? payload?.gate_envelope ?? ''),
envelope_hash: String(message.envelope_hash ?? payload?.envelope_hash ?? ''),
};
}
export function invalidateGateMessageSnapshot(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
gateMessageCache.clear();
gateMessageFetchInflight.clear();
gateMessageWaitInflight.clear();
return;
}
gateMessageCache.delete(normalized);
for (const [key, entry] of gateMessageFetchInflight.entries()) {
if (entry.gateId === normalized) {
gateMessageFetchInflight.delete(key);
}
}
for (const [key, entry] of gateMessageWaitInflight.entries()) {
if (entry.gateId === normalized) {
gateMessageWaitInflight.delete(key);
}
}
}
function gateMessageFetchKey(gateId: string, limit: number): string {
return `${gateId}::fetch::${Math.max(1, Number(limit || 20))}`;
}
function gateMessageWaitKey(gateId: string, afterCursor: number, limit: number): string {
return `${gateId}::wait::${Math.max(0, Number(afterCursor || 0))}::${Math.max(1, Number(limit || 20))}`;
}
function sliceGateMessageSnapshotState(
snapshot: GateMessageSnapshotState,
limit: number,
): GateMessageSnapshotState {
return {
...snapshot,
messages: snapshot.messages.slice(0, Math.max(1, Number(limit || 20))),
};
}
function findReusableGateMessageFetchInflight(
gateId: string,
limit: number,
): Promise<GateMessageSnapshotState> | null {
for (const entry of gateMessageFetchInflight.values()) {
if (entry.gateId === gateId && entry.limit >= limit) {
return entry.promise.then((snapshot) => sliceGateMessageSnapshotState(snapshot, limit));
}
}
return null;
}
function findReusableGateMessageWaitInflight(
gateId: string,
afterCursor: number,
limit: number,
): Promise<GateMessageSnapshotState> | null {
for (const entry of gateMessageWaitInflight.values()) {
if (entry.gateId === gateId && entry.afterCursor === afterCursor && entry.limit >= limit) {
return entry.promise.then((snapshot) => sliceGateMessageSnapshotState(snapshot, limit));
}
}
return null;
}
function upsertGateMessageSnapshot(
gateId: string,
limit: number,
messages: GateMessageSnapshotRecord[],
cursor: number,
): GateMessageSnapshotState {
gateMessageCache.set(gateId, {
limit,
value: messages,
expiresAt: Date.now() + gateMessagesTtlMs(),
cursor,
});
return {
messages: messages.slice(0, limit),
cursor,
};
}
export function getGateMessageSnapshotCursor(gateId: string): number {
const cached = gateMessageCache.get(normalizeGateId(gateId));
return cached ? Math.max(0, Number(cached.cursor || 0)) : 0;
}
export async function fetchGateMessageSnapshotState(
gateId: string,
limit: number = 20,
options: { force?: boolean; signal?: AbortSignal; proofMode?: GateAccessHeaderMode } = {},
): Promise<GateMessageSnapshotState> {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate) {
return { messages: [], cursor: 0 };
}
const normalizedLimit = Math.max(1, Number(limit || 20));
const cached = gateMessageCache.get(normalizedGate);
if (
!options.force &&
cached &&
cached.expiresAt > Date.now() &&
cached.limit >= normalizedLimit
) {
return {
messages: cached.value.slice(0, normalizedLimit),
cursor: Math.max(0, Number(cached.cursor || 0)),
};
}
const inflightKey = gateMessageFetchKey(normalizedGate, normalizedLimit);
if (!options.force) {
const inflight = gateMessageFetchInflight.get(inflightKey)?.promise;
if (inflight) {
return inflight;
}
const reusableInflight = findReusableGateMessageFetchInflight(normalizedGate, normalizedLimit);
if (reusableInflight) {
return reusableInflight;
}
}
const pending = (async () => {
const headers = await buildGateAccessHeaders(normalizedGate, {
mode: options.proofMode === 'session_stream' ? 'session_stream' : 'default',
});
if (!headers) {
throw new Error('Gate proof unavailable');
}
const response = await fetch(
`${API_BASE}/api/mesh/infonet/messages?gate=${encodeURIComponent(normalizedGate)}&limit=${normalizedLimit}`,
{ headers, signal: options.signal },
);
const data = await response.json().catch(() => ({}));
const messages = (Array.isArray(data?.messages) ? data.messages : []).map((message: unknown) =>
normalizeGateMessageSnapshotRecord(message as GateMessageSnapshotRecord),
);
const cursor = Math.max(0, Number(data?.cursor || messages.length || 0));
return upsertGateMessageSnapshot(normalizedGate, normalizedLimit, messages, cursor);
})();
if (!options.force) {
gateMessageFetchInflight.set(inflightKey, {
gateId: normalizedGate,
limit: normalizedLimit,
promise: pending,
});
}
try {
return await pending;
} finally {
gateMessageFetchInflight.delete(inflightKey);
}
}
export async function fetchGateMessageSnapshot(
gateId: string,
limit: number = 20,
options: { force?: boolean; signal?: AbortSignal } = {},
): Promise<GateMessageSnapshotRecord[]> {
const snapshot = await fetchGateMessageSnapshotState(gateId, limit, options);
return snapshot.messages;
}
export async function waitForGateMessageSnapshot(
gateId: string,
afterCursor: number,
limit: number = 20,
options: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<GateMessageSnapshotState> {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate) {
return { messages: [], cursor: 0, changed: false };
}
const normalizedLimit = Math.max(1, Number(limit || 20));
const normalizedAfterCursor = Math.max(0, Number(afterCursor || 0));
const timeoutMs = Math.max(1_000, Number(options.timeoutMs || 25_000));
const inflightKey = gateMessageWaitKey(normalizedGate, normalizedAfterCursor, normalizedLimit);
const inflight = gateMessageWaitInflight.get(inflightKey)?.promise;
if (inflight) {
return inflight;
}
const reusableInflight = findReusableGateMessageWaitInflight(
normalizedGate,
normalizedAfterCursor,
normalizedLimit,
);
if (reusableInflight) {
return reusableInflight;
}
const pending = (async () => {
const headers = await buildGateAccessHeaders(normalizedGate, { mode: 'wait' });
if (!headers) {
throw new Error('Gate proof unavailable');
}
const response = await fetch(
`${API_BASE}/api/mesh/infonet/messages/wait?gate=${encodeURIComponent(normalizedGate)}&after=${normalizedAfterCursor}&limit=${normalizedLimit}&timeout_ms=${timeoutMs}`,
{ headers, signal: options.signal },
);
const data = await response.json().catch(() => ({}));
const messages = (Array.isArray(data?.messages) ? data.messages : []).map((message: unknown) =>
normalizeGateMessageSnapshotRecord(message as GateMessageSnapshotRecord),
);
const cursor = Math.max(0, Number(data?.cursor || messages.length || normalizedAfterCursor));
const changed = Boolean(data?.changed);
const snapshot = upsertGateMessageSnapshot(
normalizedGate,
normalizedLimit,
messages,
cursor,
);
return {
...snapshot,
changed,
};
})();
gateMessageWaitInflight.set(inflightKey, {
gateId: normalizedGate,
afterCursor: normalizedAfterCursor,
limit: normalizedLimit,
promise: pending,
});
try {
return await pending;
} finally {
gateMessageWaitInflight.delete(inflightKey);
}
}
+85
View File
@@ -0,0 +1,85 @@
import { hasLocalControlBridge } from '@/lib/localControlTransport';
import {
GATE_ACTIVITY_REFRESH_JITTER_MS,
GATE_ACTIVITY_REFRESH_MS,
GATE_MESSAGES_POLL_JITTER_MS,
GATE_MESSAGES_POLL_MS,
} from '@/components/MeshChat/types';
import { jitterDelay } from '@/components/MeshChat/utils';
const GATE_BACKGROUND_MESSAGES_POLL_MS = 60_000;
const GATE_BACKGROUND_MESSAGES_POLL_JITTER_MS = 12_000;
const GATE_BACKGROUND_ACTIVITY_REFRESH_MS = 18_000;
const GATE_BACKGROUND_ACTIVITY_REFRESH_JITTER_MS = 4_000;
const GATE_MESSAGES_WAIT_MS = 32_000;
const GATE_MESSAGES_WAIT_JITTER_MS = 6_000;
const GATE_BACKGROUND_MESSAGES_WAIT_MS = 72_000;
const GATE_BACKGROUND_MESSAGES_WAIT_JITTER_MS = 12_000;
const GATE_MESSAGES_WAIT_REARM_MS = 3_600;
const GATE_MESSAGES_WAIT_REARM_JITTER_MS = 600;
const GATE_BACKGROUND_MESSAGES_WAIT_REARM_MS = 9_000;
const GATE_BACKGROUND_MESSAGES_WAIT_REARM_JITTER_MS = 3_000;
export function shouldJitterGateMetadataTiming(): boolean {
return !hasLocalControlBridge();
}
function shouldCoarsenBackgroundGateTiming(): boolean {
return (
shouldJitterGateMetadataTiming() &&
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
);
}
export function nextGateMessagesPollDelayMs(): number {
if (!shouldJitterGateMetadataTiming()) {
return GATE_MESSAGES_POLL_MS;
}
if (shouldCoarsenBackgroundGateTiming()) {
return jitterDelay(
GATE_BACKGROUND_MESSAGES_POLL_MS,
GATE_BACKGROUND_MESSAGES_POLL_JITTER_MS,
);
}
return jitterDelay(GATE_MESSAGES_POLL_MS, GATE_MESSAGES_POLL_JITTER_MS);
}
export function nextGateActivityRefreshDelayMs(): number {
if (!shouldJitterGateMetadataTiming()) {
return 0;
}
if (shouldCoarsenBackgroundGateTiming()) {
return jitterDelay(
GATE_BACKGROUND_ACTIVITY_REFRESH_MS,
GATE_BACKGROUND_ACTIVITY_REFRESH_JITTER_MS,
);
}
return jitterDelay(GATE_ACTIVITY_REFRESH_MS, GATE_ACTIVITY_REFRESH_JITTER_MS);
}
export function nextGateMessagesWaitTimeoutMs(): number {
if (!shouldJitterGateMetadataTiming()) {
return 20_000;
}
if (shouldCoarsenBackgroundGateTiming()) {
return jitterDelay(
GATE_BACKGROUND_MESSAGES_WAIT_MS,
GATE_BACKGROUND_MESSAGES_WAIT_JITTER_MS,
);
}
return jitterDelay(GATE_MESSAGES_WAIT_MS, GATE_MESSAGES_WAIT_JITTER_MS);
}
export function nextGateMessagesWaitRearmDelayMs(): number {
if (!shouldJitterGateMetadataTiming()) {
return 750;
}
if (shouldCoarsenBackgroundGateTiming()) {
return jitterDelay(
GATE_BACKGROUND_MESSAGES_WAIT_REARM_MS,
GATE_BACKGROUND_MESSAGES_WAIT_REARM_JITTER_MS,
);
}
return jitterDelay(GATE_MESSAGES_WAIT_REARM_MS, GATE_MESSAGES_WAIT_REARM_JITTER_MS);
}
+101
View File
@@ -0,0 +1,101 @@
import { hasLocalControlBridge } from '@/lib/localControlTransport';
import { gateEnvelopeDisplayText, isEncryptedGateEnvelope } from '@/mesh/gateEnvelope';
import {
fetchGateMessageSnapshot,
invalidateGateMessageSnapshot,
type GateMessageSnapshotRecord,
} from '@/mesh/gateMessageSnapshot';
import { decryptWormholeGateMessage } from '@/mesh/wormholeIdentityClient';
export interface GateThreadPreviewSnapshot {
nodeId: string;
age: string;
text: string;
encrypted?: boolean;
}
const GATE_PREVIEW_BROWSER_TTL_MS = 12_000;
const GATE_PREVIEW_NATIVE_TTL_MS = 4_000;
const gatePreviewCache = new Map<
string,
{ value: GateThreadPreviewSnapshot[]; expiresAt: number }
>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function gatePreviewTtlMs(): number {
return hasLocalControlBridge()
? GATE_PREVIEW_NATIVE_TTL_MS
: GATE_PREVIEW_BROWSER_TTL_MS;
}
function previewAge(timestamp: number): string {
const ageMin = Math.floor((Date.now() / 1000 - Number(timestamp || 0)) / 60);
return ageMin < 60 ? `${ageMin}m ago` : `${Math.floor(ageMin / 60)}h ago`;
}
export async function describeGateMessagePreview(message: GateMessageSnapshotRecord): Promise<string> {
const normalized = message;
if (message.system_seed) {
return String(message.message || '').slice(0, 120);
}
if (!isEncryptedGateEnvelope(normalized)) {
return String(normalized.message || '').slice(0, 80);
}
try {
const decrypted = await decryptWormholeGateMessage(
String(normalized.gate || ''),
Number(normalized.epoch || 0),
String(normalized.ciphertext || ''),
String(normalized.nonce || ''),
String(normalized.sender_ref || ''),
String(normalized.gate_envelope || ''),
String(normalized.envelope_hash || ''),
);
return gateEnvelopeDisplayText({
...normalized,
decrypted_message: decrypted.ok ? decrypted.plaintext : '',
}).slice(0, 120);
} catch {
return gateEnvelopeDisplayText(normalized).slice(0, 120);
}
}
export function invalidateGateThreadPreviewSnapshot(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
gatePreviewCache.clear();
invalidateGateMessageSnapshot();
return;
}
gatePreviewCache.delete(normalized);
invalidateGateMessageSnapshot(normalized);
}
export async function fetchGateThreadPreviewSnapshot(
gateId: string,
options: { force?: boolean } = {},
): Promise<GateThreadPreviewSnapshot[]> {
const normalizedGate = normalizeGateId(gateId);
const cached = gatePreviewCache.get(normalizedGate);
if (!options.force && cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const messages = (await fetchGateMessageSnapshot(normalizedGate, 6, options)).slice(0, 4);
const previews = await Promise.all(
messages.map(async (message) => ({
nodeId: String(message.node_id || ''),
age: previewAge(message.timestamp),
text: await describeGateMessagePreview(message),
encrypted: isEncryptedGateEnvelope(message),
})),
);
gatePreviewCache.set(normalizedGate, {
value: previews,
expiresAt: Date.now() + gatePreviewTtlMs(),
});
return previews;
}
+555
View File
@@ -0,0 +1,555 @@
import { controlPlaneFetch } from '@/lib/controlPlane';
export type GateSessionStreamPhase =
| 'idle'
| 'connecting'
| 'open'
| 'closed'
| 'disabled'
| 'error';
export interface GateSessionStreamStatus {
enabled: boolean;
phase: GateSessionStreamPhase;
transport: 'sse';
sessionId: string;
subscriptions: string[];
heartbeatS: number;
batchMs: number;
lastEventType: string;
lastEventAt: number;
detail: string;
}
export interface GateSessionStreamAccess {
node_id: string;
proof: string;
ts: string;
}
export interface GateSessionStreamKeyStatus {
ok?: boolean;
gate_id?: string;
current_epoch?: number;
has_local_access?: boolean;
identity_scope?: string;
identity_node_id?: string;
identity_persona_id?: string;
detail?: string;
format?: string;
}
type GateSessionStreamListener = (status: GateSessionStreamStatus) => void;
type GateSessionStreamEventListener = (event: {
event: string;
data: unknown;
at: number;
}) => void;
const gateSessionStreamListeners = new Set<GateSessionStreamListener>();
const gateSessionStreamEventListeners = new Set<GateSessionStreamEventListener>();
const gateSessionStreamRetainCounts = new Map<string, number>();
const gateSessionStreamSubscriptions = new Set<string>();
const gateSessionStreamGateAccess = new Map<string, GateSessionStreamAccess>();
const gateSessionStreamGateKeyStatus = new Map<string, GateSessionStreamKeyStatus>();
const GATE_SESSION_STREAM_RECONNECT_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] as const;
let gateSessionStreamAbort: AbortController | null = null;
let gateSessionStreamTask: Promise<void> | null = null;
let gateSessionStreamConnectSignature = '';
let gateSessionStreamReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let gateSessionStreamReconnectAttempt = 0;
let gateSessionStreamStatus: GateSessionStreamStatus = {
enabled: false,
phase: 'idle',
transport: 'sse',
sessionId: '',
subscriptions: [],
heartbeatS: 0,
batchMs: 0,
lastEventType: '',
lastEventAt: 0,
detail: '',
};
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function gateSessionStreamSnapshot(): GateSessionStreamStatus {
return {
...gateSessionStreamStatus,
subscriptions: Array.from(gateSessionStreamSubscriptions),
};
}
function gateSessionStreamSubscriptionSignature(): string {
return Array.from(gateSessionStreamSubscriptions).sort().join(',');
}
function clearGateSessionStreamReconnect(): void {
if (gateSessionStreamReconnectTimer) {
clearTimeout(gateSessionStreamReconnectTimer);
gateSessionStreamReconnectTimer = null;
}
}
function emitGateSessionStreamStatus(): void {
const snapshot = gateSessionStreamSnapshot();
for (const listener of gateSessionStreamListeners) {
listener(snapshot);
}
}
function emitGateSessionStreamEvent(event: string, data: unknown, at: number): void {
for (const listener of gateSessionStreamEventListeners) {
listener({ event, data, at });
}
}
function updateGateSessionStreamStatus(
patch: Partial<Omit<GateSessionStreamStatus, 'subscriptions'>>,
): void {
gateSessionStreamStatus = {
...gateSessionStreamStatus,
...patch,
};
emitGateSessionStreamStatus();
}
function syncGateSessionStreamSubscriptionsFromRetains(): void {
gateSessionStreamSubscriptions.clear();
for (const [gateId, count] of gateSessionStreamRetainCounts.entries()) {
if (count > 0) {
gateSessionStreamSubscriptions.add(gateId);
}
}
}
function clearGateSessionStreamGateContext(): void {
gateSessionStreamGateAccess.clear();
gateSessionStreamGateKeyStatus.clear();
}
function parseGateSessionStreamEvent(block: string): {
event: string;
data: unknown;
} | null {
const lines = block
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.length > 0 && !line.startsWith(':'));
if (!lines.length) return null;
let event = 'message';
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (!dataLines.length) {
return { event, data: null };
}
const rawData = dataLines.join('\n');
try {
return { event, data: JSON.parse(rawData) };
} catch {
return { event, data: rawData };
}
}
function handleGateSessionStreamEvent(event: string, payload: unknown): void {
const ts = Date.now();
emitGateSessionStreamEvent(event, payload, ts);
if (event === 'hello' && payload && typeof payload === 'object') {
const hello = payload as {
session_id?: string;
subscriptions?: unknown;
gate_access?: unknown;
gate_key_status?: unknown;
heartbeat_s?: number;
batch_ms?: number;
transport?: string;
};
gateSessionStreamSubscriptions.clear();
if (Array.isArray(hello.subscriptions)) {
for (const gateId of hello.subscriptions) {
const normalized = normalizeGateId(String(gateId || ''));
if (normalized) {
gateSessionStreamSubscriptions.add(normalized);
}
}
}
clearGateSessionStreamGateContext();
if (hello.gate_access && typeof hello.gate_access === 'object') {
for (const [gateId, access] of Object.entries(hello.gate_access as Record<string, unknown>)) {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate || !access || typeof access !== 'object') continue;
const accessRecord = access as Record<string, unknown>;
const nodeId = String(accessRecord.node_id || '').trim();
const proof = String(accessRecord.proof || '').trim();
const ts = String(accessRecord.ts || '').trim();
if (!nodeId || !proof || !ts) continue;
gateSessionStreamGateAccess.set(normalizedGate, { node_id: nodeId, proof, ts });
}
}
if (hello.gate_key_status && typeof hello.gate_key_status === 'object') {
for (const [gateId, status] of Object.entries(hello.gate_key_status as Record<string, unknown>)) {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate || !status || typeof status !== 'object') continue;
gateSessionStreamGateKeyStatus.set(normalizedGate, {
...(status as GateSessionStreamKeyStatus),
gate_id: normalizedGate,
});
}
}
clearGateSessionStreamReconnect();
gateSessionStreamReconnectAttempt = 0;
updateGateSessionStreamStatus({
enabled: true,
phase: 'open',
transport: hello.transport === 'sse' ? 'sse' : 'sse',
sessionId: String(hello.session_id || ''),
heartbeatS: Math.max(0, Number(hello.heartbeat_s || 0)),
batchMs: Math.max(0, Number(hello.batch_ms || 0)),
lastEventType: 'hello',
lastEventAt: ts,
detail: '',
});
return;
}
updateGateSessionStreamStatus({
lastEventType: event,
lastEventAt: ts,
});
}
function scheduleGateSessionStreamReconnect(): void {
syncGateSessionStreamSubscriptionsFromRetains();
if (!gateSessionStreamSubscriptionSignature()) {
return;
}
if (gateSessionStreamStatus.phase === 'disabled' || gateSessionStreamReconnectTimer) {
return;
}
const delayMs =
GATE_SESSION_STREAM_RECONNECT_DELAYS_MS[
Math.min(gateSessionStreamReconnectAttempt, GATE_SESSION_STREAM_RECONNECT_DELAYS_MS.length - 1)
];
gateSessionStreamReconnectAttempt += 1;
gateSessionStreamReconnectTimer = setTimeout(() => {
gateSessionStreamReconnectTimer = null;
void reconcileGateSessionStreamConnection();
}, delayMs);
}
async function consumeGateSessionStreamBody(
response: Response,
signal: AbortSignal,
): Promise<void> {
const body = response.body;
if (!body) {
updateGateSessionStreamStatus({
enabled: false,
phase: 'error',
detail: 'gate_session_stream_body_missing',
});
return;
}
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const normalizedBuffer = buffer.replace(/\r\n/g, '\n');
let delimiter = normalizedBuffer.indexOf('\n\n');
if (delimiter >= 0) {
let remaining = normalizedBuffer;
while (delimiter >= 0) {
const block = remaining.slice(0, delimiter);
const parsed = parseGateSessionStreamEvent(block);
if (parsed) {
handleGateSessionStreamEvent(parsed.event, parsed.data);
}
remaining = remaining.slice(delimiter + 2);
delimiter = remaining.indexOf('\n\n');
}
buffer = remaining;
} else {
buffer = normalizedBuffer;
}
if (done) {
const trailing = buffer.trim();
if (trailing) {
const parsed = parseGateSessionStreamEvent(trailing);
if (parsed) {
handleGateSessionStreamEvent(parsed.event, parsed.data);
}
}
break;
}
if (signal.aborted) {
return;
}
}
if (!signal.aborted) {
updateGateSessionStreamStatus({
enabled: false,
phase: 'closed',
detail: 'gate_session_stream_closed',
});
scheduleGateSessionStreamReconnect();
}
}
export function getGateSessionStreamStatus(): GateSessionStreamStatus {
return gateSessionStreamSnapshot();
}
export function subscribeGateSessionStreamStatus(
listener: GateSessionStreamListener,
): () => void {
gateSessionStreamListeners.add(listener);
listener(gateSessionStreamSnapshot());
return () => {
gateSessionStreamListeners.delete(listener);
};
}
export function subscribeGateSessionStreamEvents(
listener: GateSessionStreamEventListener,
): () => void {
gateSessionStreamEventListeners.add(listener);
return () => {
gateSessionStreamEventListeners.delete(listener);
};
}
export function getGateSessionStreamAccessHeaders(gateId: string): Record<string, string> | undefined {
const access = gateSessionStreamGateAccess.get(normalizeGateId(gateId));
if (!access) return undefined;
return {
'X-Wormhole-Node-Id': access.node_id,
'X-Wormhole-Gate-Proof': access.proof,
'X-Wormhole-Gate-Ts': access.ts,
};
}
export function getGateSessionStreamKeyStatus(gateId: string): GateSessionStreamKeyStatus | null {
return gateSessionStreamGateKeyStatus.get(normalizeGateId(gateId)) || null;
}
export function setGateSessionStreamGateContext(
gateId: string,
options: {
accessHeaders?: Record<string, string> | null;
keyStatus?: GateSessionStreamKeyStatus | null;
},
): void {
const normalized = normalizeGateId(gateId);
if (!normalized) return;
const accessHeaders = options.accessHeaders;
if (accessHeaders) {
const nodeId = String(accessHeaders['X-Wormhole-Node-Id'] || '').trim();
const proof = String(accessHeaders['X-Wormhole-Gate-Proof'] || '').trim();
const ts = String(accessHeaders['X-Wormhole-Gate-Ts'] || '').trim();
if (nodeId && proof && ts) {
gateSessionStreamGateAccess.set(normalized, { node_id: nodeId, proof, ts });
}
}
const keyStatus = options.keyStatus;
if (keyStatus && typeof keyStatus === 'object') {
gateSessionStreamGateKeyStatus.set(normalized, {
...keyStatus,
gate_id: normalized,
});
}
}
export function invalidateGateSessionStreamGateContext(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
clearGateSessionStreamGateContext();
return;
}
gateSessionStreamGateAccess.delete(normalized);
gateSessionStreamGateKeyStatus.delete(normalized);
}
export function setGateSessionStreamSubscriptions(gates: Iterable<string>): void {
gateSessionStreamRetainCounts.clear();
gateSessionStreamSubscriptions.clear();
clearGateSessionStreamGateContext();
for (const gateId of gates) {
const normalized = normalizeGateId(String(gateId || ''));
if (normalized) {
gateSessionStreamSubscriptions.add(normalized);
gateSessionStreamRetainCounts.set(normalized, 1);
}
}
emitGateSessionStreamStatus();
}
export function disconnectGateSessionStream(detail: string = 'gate_session_stream_stopped'): void {
const controller = gateSessionStreamAbort;
gateSessionStreamAbort = null;
gateSessionStreamTask = null;
clearGateSessionStreamReconnect();
gateSessionStreamReconnectAttempt = 0;
if (controller) {
controller.abort();
}
gateSessionStreamConnectSignature = '';
clearGateSessionStreamGateContext();
updateGateSessionStreamStatus({
enabled: false,
phase: 'closed',
detail,
});
}
export function connectGateSessionStream(options: { enabled?: boolean } = {}): GateSessionStreamStatus {
if (options.enabled === false) {
disconnectGateSessionStream('gate_session_stream_disabled');
updateGateSessionStreamStatus({
phase: 'disabled',
detail: 'gate_session_stream_disabled',
});
return gateSessionStreamSnapshot();
}
if (gateSessionStreamTask) {
return gateSessionStreamSnapshot();
}
clearGateSessionStreamReconnect();
gateSessionStreamConnectSignature = gateSessionStreamSubscriptionSignature();
const controller = new AbortController();
gateSessionStreamAbort = controller;
updateGateSessionStreamStatus({
enabled: true,
phase: 'connecting',
sessionId: '',
heartbeatS: 0,
batchMs: 0,
lastEventType: '',
lastEventAt: 0,
detail: '',
});
const params = new URLSearchParams();
const gates = Array.from(gateSessionStreamSubscriptions);
if (gates.length) {
params.set('gates', gates.join(','));
}
const path = `/api/mesh/infonet/session-stream${params.size ? `?${params.toString()}` : ''}`;
gateSessionStreamTask = (async () => {
try {
const response = await controlPlaneFetch(path, {
requireAdminSession: true,
cache: 'no-store',
headers: { Accept: 'text/event-stream' },
signal: controller.signal,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
const detail = String(data?.detail || 'gate_session_stream_unavailable');
updateGateSessionStreamStatus({
enabled: false,
phase: detail === 'gate_session_stream_disabled' ? 'disabled' : 'error',
detail,
});
if (detail !== 'gate_session_stream_disabled') {
scheduleGateSessionStreamReconnect();
}
return;
}
await consumeGateSessionStreamBody(response, controller.signal);
} catch (error) {
if (controller.signal.aborted) {
return;
}
updateGateSessionStreamStatus({
enabled: false,
phase: 'error',
detail:
error instanceof Error && error.message
? error.message
: 'gate_session_stream_failed',
});
scheduleGateSessionStreamReconnect();
} finally {
if (gateSessionStreamAbort === controller) {
gateSessionStreamAbort = null;
}
gateSessionStreamTask = null;
}
})();
return gateSessionStreamSnapshot();
}
function reconcileGateSessionStreamConnection(): GateSessionStreamStatus {
syncGateSessionStreamSubscriptionsFromRetains();
const signature = gateSessionStreamSubscriptionSignature();
emitGateSessionStreamStatus();
if (!signature) {
clearGateSessionStreamReconnect();
gateSessionStreamReconnectAttempt = 0;
if (gateSessionStreamTask || gateSessionStreamAbort) {
disconnectGateSessionStream('gate_session_stream_idle');
}
updateGateSessionStreamStatus({
enabled: false,
phase: 'idle',
sessionId: '',
heartbeatS: 0,
batchMs: 0,
lastEventType: '',
lastEventAt: 0,
detail: '',
});
return gateSessionStreamSnapshot();
}
if (gateSessionStreamStatus.phase === 'disabled') {
clearGateSessionStreamReconnect();
return gateSessionStreamSnapshot();
}
if (gateSessionStreamTask && gateSessionStreamConnectSignature === signature) {
return gateSessionStreamSnapshot();
}
if (gateSessionStreamTask || gateSessionStreamAbort) {
disconnectGateSessionStream('gate_session_stream_restarting');
}
return connectGateSessionStream();
}
export function retainGateSessionStreamGate(gateId: string): () => void {
const normalized = normalizeGateId(gateId);
if (!normalized) {
return () => {};
}
gateSessionStreamRetainCounts.set(
normalized,
Math.max(0, Number(gateSessionStreamRetainCounts.get(normalized) || 0)) + 1,
);
reconcileGateSessionStreamConnection();
return () => {
releaseGateSessionStreamGate(normalized);
};
}
export function releaseGateSessionStreamGate(gateId: string): GateSessionStreamStatus {
const normalized = normalizeGateId(gateId);
if (!normalized) {
return gateSessionStreamSnapshot();
}
const current = Math.max(0, Number(gateSessionStreamRetainCounts.get(normalized) || 0));
if (current <= 1) {
gateSessionStreamRetainCounts.delete(normalized);
} else {
gateSessionStreamRetainCounts.set(normalized, current - 1);
}
return reconcileGateSessionStreamConnection();
}
+769
View File
@@ -0,0 +1,769 @@
/**
* Infonet economy / governance / gates / bootstrap HTTP client.
*
* Pairs with backend/routers/infonet.py. Every function returns the
* shape declared by the router; if the backend is unavailable, the
* returned promise rejects with the network error.
*
* Cross-cutting design rule (BUILD_LOG.md):
* - Errors surfaced from validation are diagnostic, not punitive.
* The `ok: false, reason: "..."` shape carries a specific failure
* so the UI can render "you need 5 more rep" instead of "denied".
*/
const BASE = '/api/infonet';
async function jsonGet<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { credentials: 'include' });
if (!res.ok && res.status !== 400) {
throw new Error(`infonet ${path}: HTTP ${res.status}`);
}
return (await res.json()) as T;
}
async function jsonPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'include',
});
// 400 is "ok-shaped error" carrying {ok:false, reason}; let it through.
if (!res.ok && res.status !== 400) {
throw new Error(`infonet ${path}: HTTP ${res.status}`);
}
return (await res.json()) as T;
}
// ─── Status ──────────────────────────────────────────────────────────────
export interface RampFlags {
node_count: number;
bootstrap_resolution_active: boolean;
staked_resolution_active: boolean;
governance_petitions_active: boolean;
upgrade_governance_active: boolean;
commoncoin_active: boolean;
}
export interface InfonetStatus {
ok: true;
now: number;
chain_majority_time: number;
chain_event_count: number;
chain_stale: boolean;
ramp: RampFlags;
privacy_primitive_status: {
ringct: string;
stealth_address: string;
shielded_balance: string;
dex: string;
};
immutable_principles: Record<string, string | boolean>;
config_keys_count: number;
infonet_economy_event_types_count: number;
}
export function fetchInfonetStatus(): Promise<InfonetStatus> {
return jsonGet<InfonetStatus>('/status');
}
// ─── Petitions ───────────────────────────────────────────────────────────
export type PetitionPayload =
| { type: 'UPDATE_PARAM'; key: string; value: unknown }
| { type: 'BATCH_UPDATE_PARAMS'; updates: Array<{ key: string; value: unknown }> }
| { type: 'ENABLE_FEATURE'; feature: string }
| { type: 'DISABLE_FEATURE'; feature: string };
export interface PetitionState {
petition_id: string;
status: string;
filer_id: string;
filed_at: number;
petition_payload: PetitionPayload | Record<string, unknown>;
signature_governance_weight: number;
signature_threshold_at_filing: number;
votes_for_weight: number;
votes_against_weight: number;
voting_deadline: number | null;
challenge_window_until: number | null;
}
export interface PetitionsList {
ok: true;
now: number;
petitions: PetitionState[];
}
export function fetchPetitions(): Promise<PetitionsList> {
return jsonGet<PetitionsList>('/petitions');
}
export interface PetitionPreview {
ok: boolean;
changed_keys?: string[];
new_values?: Record<string, unknown>;
reason?: string;
}
export function previewPetitionPayload(
payload: PetitionPayload,
): Promise<PetitionPreview> {
return jsonPost<PetitionPreview>('/petitions/preview', payload);
}
// ─── Event payload validation ────────────────────────────────────────────
export interface EventValidation {
ok: boolean;
reason: string | null;
tier: string;
would_be_provisional: boolean;
}
export function validateEventPayload(
event_type: string,
payload: Record<string, unknown>,
): Promise<EventValidation> {
return jsonPost<EventValidation>('/events/validate', { event_type, payload });
}
// ─── Upgrades ────────────────────────────────────────────────────────────
export interface UpgradeProposalSummary {
proposal_id: string;
status: string;
proposer_id: string;
filed_at: number;
release_hash: string;
target_protocol_version: string;
votes_for_weight: number;
votes_against_weight: number;
readiness_fraction: number;
readiness_threshold_met: boolean;
}
export function fetchUpgrades(): Promise<{ ok: true; now: number; upgrades: UpgradeProposalSummary[] }> {
return jsonGet('/upgrades');
}
export function fetchUpgrade(proposalId: string) {
return jsonGet<{ ok: true; now: number; upgrade: Record<string, unknown> }>(
`/upgrades/${encodeURIComponent(proposalId)}`,
);
}
// ─── Markets ─────────────────────────────────────────────────────────────
export interface EvidenceBundleSummary {
node_id: string;
claimed_outcome: 'yes' | 'no';
evidence_hashes: string[];
source_description: string;
bond: number;
timestamp: number;
is_first_for_side: boolean;
submission_hash: string;
}
export interface DisputeSummary {
dispute_id: string;
challenger_id: string;
challenger_stake: number;
opened_at: number;
is_resolved: boolean;
resolved_outcome: string | null;
confirm_stakes: Array<{ node_id: string; amount: number; rep_type: string }>;
reverse_stakes: Array<{ node_id: string; amount: number; rep_type: string }>;
}
export interface MarketState {
ok: true;
market_id: string;
status: string;
snapshot: Record<string, unknown> | null;
evidence_bundles: EvidenceBundleSummary[];
excluded_predictor_ids: string[];
disputes: DisputeSummary[];
was_reversed: boolean;
now: number;
}
export function fetchMarketState(marketId: string): Promise<MarketState> {
return jsonGet<MarketState>(`/markets/${encodeURIComponent(marketId)}`);
}
export interface ResolutionPreview {
ok: true;
preview: {
outcome: 'yes' | 'no' | 'invalid';
reason: string;
is_provisional: boolean;
burned_amount: number;
stake_returns: Array<{ node_id: string; rep_type: string; amount: number }>;
stake_winnings: Array<{ node_id: string; rep_type: string; amount: number }>;
bond_returns: Array<{ node_id: string; amount: number }>;
bond_forfeits: Array<{ node_id: string; amount: number }>;
first_submitter_bonuses: Array<{ node_id: string; amount: number }>;
};
}
export function previewMarketResolution(marketId: string): Promise<ResolutionPreview> {
return jsonGet<ResolutionPreview>(
`/markets/${encodeURIComponent(marketId)}/preview-resolution`,
);
}
// ─── Gates ───────────────────────────────────────────────────────────────
export interface GateMetaSummary {
creator_node_id: string;
display_name: string;
entry_sacrifice: number;
min_overall_rep: number;
min_gate_rep: Record<string, number>;
created_at: number;
}
export interface GateState {
ok: true;
gate_id: string;
meta: GateMetaSummary;
members: string[];
ratified: boolean;
cumulative_member_oracle_rep: number;
locked: { is_locked: boolean; locked_at: number | null; locked_by: string[] };
suspension: {
status: 'active' | 'suspended' | 'shutdown';
suspended_at: number | null;
suspended_until: number | null;
last_shutdown_petition_at: number | null;
};
shutdown: {
has_pending: boolean;
pending_petition_id: string | null;
pending_status: string | null;
execution_at: number | null;
executed: boolean;
};
now: number;
}
export interface GateNotFound {
ok: false;
reason: string;
}
export function fetchGateState(gateId: string): Promise<GateState | GateNotFound> {
return jsonGet<GateState | GateNotFound>(`/gates/${encodeURIComponent(gateId)}`);
}
// ─── Reputation ──────────────────────────────────────────────────────────
export interface NodeReputation {
ok: true;
node_id: string;
oracle_rep: number;
oracle_rep_active: number;
oracle_rep_lifetime: number;
common_rep: number;
decay_factor: number;
last_successful_prediction_ts: number | null;
breakdown: {
free_prediction_mints: number;
staked_prediction_returns: number;
staked_prediction_losses: number;
total: number;
};
}
export function fetchNodeReputation(nodeId: string): Promise<NodeReputation> {
return jsonGet<NodeReputation>(`/nodes/${encodeURIComponent(nodeId)}/reputation`);
}
// ─── Bootstrap ───────────────────────────────────────────────────────────
export interface BootstrapMarketState {
ok: true;
market_id: string;
votes: Array<{
node_id: string;
side: string;
eligible: boolean;
ineligible_reason: string | null;
}>;
tally: {
yes: number;
no: number;
total_eligible: number;
min_market_participants: number;
supermajority_threshold: number;
};
}
export function fetchBootstrapMarketState(marketId: string): Promise<BootstrapMarketState> {
return jsonGet<BootstrapMarketState>(`/bootstrap/markets/${encodeURIComponent(marketId)}`);
}
// ─── Signed write: append an Infonet economy event ───────────────────────
/**
* Pre-signed event payload to append to the chain.
*
* The CALLER signs the canonical payload using the local node's
* private key before submitting. ``mesh_hashchain.Infonet.append``
* (the secure server-side entry point) verifies signature, public-key
* binding, replay protection, and sequence ordering.
*
* Production frontend code uses ``meshIdentity.signEventPayload(...)``
* (or equivalent) to produce the signature before calling this.
*/
export interface SignedEventBody {
event_type: string;
node_id: string;
payload: Record<string, unknown>;
signature: string; // hex
sequence: number; // node-monotonic, > 0
public_key: string; // base64
public_key_algo: 'ed25519' | 'ecdsa';
protocol_version?: string;
}
export interface AppendOk {
ok: true;
event: {
event_id: string;
event_type: string;
node_id: string;
timestamp: number;
sequence: number;
payload: Record<string, unknown>;
[key: string]: unknown;
};
}
export interface AppendError {
ok: false;
reason: string;
}
export type AppendResult = AppendOk | AppendError;
/**
* Append a signed Infonet economy event to the chain.
*
* Cross-cutting non-hostile UX rule: ``reason`` on failure carries
* the verbatim diagnostic from the secure entry point — surface it
* directly in the UI so the user can act on it.
*/
export function appendEvent(body: SignedEventBody): Promise<AppendResult> {
return jsonPost<AppendResult>('/append', body);
}
// Convenience builders. Each builds the structured payload the
// backend validators expect, then the caller wraps with signing
// metadata before calling ``appendEvent``.
export function buildUprepPayload(
targetNodeId: string,
targetEventId: string,
): { event_type: 'uprep'; payload: Record<string, unknown> } {
return {
event_type: 'uprep',
payload: { target_node_id: targetNodeId, target_event_id: targetEventId },
};
}
export function buildPetitionFilePayload(
petitionId: string,
petitionPayload: PetitionPayload,
): { event_type: 'petition_file'; payload: Record<string, unknown> } {
return {
event_type: 'petition_file',
payload: { petition_id: petitionId, petition_payload: petitionPayload },
};
}
export function buildPetitionVotePayload(
petitionId: string,
vote: 'for' | 'against',
): { event_type: 'petition_vote'; payload: Record<string, unknown> } {
return {
event_type: 'petition_vote',
payload: { petition_id: petitionId, vote },
};
}
export function buildPetitionSignPayload(
petitionId: string,
): { event_type: 'petition_sign'; payload: Record<string, unknown> } {
return {
event_type: 'petition_sign',
payload: { petition_id: petitionId },
};
}
export function buildChallengeFilePayload(
petitionId: string,
reason: string,
): { event_type: 'challenge_file'; payload: Record<string, unknown> } {
return {
event_type: 'challenge_file',
payload: { petition_id: petitionId, reason },
};
}
export function buildGateSuspendFilePayload(
petitionId: string,
gateId: string,
reason: string,
evidenceHashes: string[],
): { event_type: 'gate_suspend_file'; payload: Record<string, unknown> } {
return {
event_type: 'gate_suspend_file',
payload: {
petition_id: petitionId,
gate_id: gateId,
reason,
evidence_hashes: evidenceHashes,
},
};
}
export function buildBootstrapResolutionVotePayload(
marketId: string,
side: 'yes' | 'no',
powNonce: number,
): { event_type: 'bootstrap_resolution_vote'; payload: Record<string, unknown> } {
return {
event_type: 'bootstrap_resolution_vote',
payload: { market_id: marketId, side, pow_nonce: powNonce },
};
}
// ─── End-to-end sign + append helper ────────────────────────────────────
/**
* Pull the local node identity, advance the sequence counter, sign the
* canonical payload via the WebCrypto helpers in ``meshIdentity``, and
* post the signed event to ``/api/infonet/append``.
*
* Cross-cutting non-hostile UX rule: every failure mode returns the
* verbatim diagnostic from the backend so the UI surfaces it directly.
*
* Returns the same ``AppendResult`` shape as ``appendEvent`` plus a
* pre-flight rejection when the local identity isn't loaded yet.
*/
export async function signAndAppend(args: {
event_type: string;
payload: Record<string, unknown>;
}): Promise<AppendResult> {
// Lazy import — keeps the client lightweight for callers that only
// use the read endpoints. Same module, same browser tab.
const meshIdentity = await import('@/mesh/meshIdentity');
const identity = meshIdentity.getNodeIdentity();
if (!identity || !identity.publicKey) {
return {
ok: false,
reason: 'node_identity_not_loaded — open the InfonetTerminal so the local key materializes, then retry',
};
}
const nodeId = await meshIdentity.deriveNodeIdFromPublicKey(identity.publicKey);
const sequence = meshIdentity.nextSequence();
let signature: string;
try {
signature = await meshIdentity.signEvent(
args.event_type,
nodeId,
sequence,
args.payload,
);
} catch (err) {
return {
ok: false,
reason: `signing_failed: ${err instanceof Error ? err.message : String(err)}`,
};
}
const algo = meshIdentity.getStoredNodeDescriptor()?.publicKeyAlgo
?? 'Ed25519';
return appendEvent({
event_type: args.event_type,
node_id: nodeId,
payload: args.payload,
signature,
sequence,
public_key: identity.publicKey,
public_key_algo: algo.toLowerCase() === 'ecdsa' ? 'ecdsa' : 'ed25519',
});
}
// ─── Additional payload builders (write-action wiring phase) ─────────────
export function buildChallengeVotePayload(
petitionId: string,
vote: 'uphold' | 'void',
): { event_type: 'challenge_vote'; payload: Record<string, unknown> } {
return {
event_type: 'challenge_vote',
payload: { petition_id: petitionId, vote },
};
}
export function buildResolutionStakePayload(
marketId: string,
side: 'yes' | 'no' | 'data_unavailable',
amount: number,
repType: 'oracle' | 'common',
): { event_type: 'resolution_stake'; payload: Record<string, unknown> } {
return {
event_type: 'resolution_stake',
payload: { market_id: marketId, side, amount, rep_type: repType },
};
}
export function buildDisputeOpenPayload(
marketId: string,
challengerStake: number,
reason: string,
): { event_type: 'dispute_open'; payload: Record<string, unknown> } {
return {
event_type: 'dispute_open',
payload: { market_id: marketId, challenger_stake: challengerStake, reason },
};
}
export function buildDisputeStakePayload(
disputeId: string,
side: 'confirm' | 'reverse',
amount: number,
repType: 'oracle' | 'common',
): { event_type: 'dispute_stake'; payload: Record<string, unknown> } {
return {
event_type: 'dispute_stake',
payload: { dispute_id: disputeId, side, amount, rep_type: repType },
};
}
export function buildGateShutdownFilePayload(
petitionId: string,
gateId: string,
reason: string,
evidenceHashes: string[],
): { event_type: 'gate_shutdown_file'; payload: Record<string, unknown> } {
return {
event_type: 'gate_shutdown_file',
payload: {
petition_id: petitionId,
gate_id: gateId,
reason,
evidence_hashes: evidenceHashes,
},
};
}
export function buildGateShutdownAppealFilePayload(
petitionId: string,
gateId: string,
targetPetitionId: string,
reason: string,
evidenceHashes: string[],
): { event_type: 'gate_shutdown_appeal_file'; payload: Record<string, unknown> } {
return {
event_type: 'gate_shutdown_appeal_file',
payload: {
petition_id: petitionId,
gate_id: gateId,
target_petition_id: targetPetitionId,
reason,
evidence_hashes: evidenceHashes,
},
};
}
export function buildUpgradeSignPayload(
proposalId: string,
): { event_type: 'upgrade_sign'; payload: Record<string, unknown> } {
return {
event_type: 'upgrade_sign',
payload: { proposal_id: proposalId },
};
}
export function buildUpgradeVotePayload(
proposalId: string,
vote: 'for' | 'against',
): { event_type: 'upgrade_vote'; payload: Record<string, unknown> } {
return {
event_type: 'upgrade_vote',
payload: { proposal_id: proposalId, vote },
};
}
/**
* Generate a fresh local-side identifier suitable for petition_id /
* dispute_id / proposal_id. Random + timestamp so refile attempts
* produce distinct IDs (no replay).
*/
export function freshLocalId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
}
// ─── Evidence canonicalization (mirrors services/infonet/markets/evidence.py) ──
function bytesToHex(bytes: ArrayBuffer): string {
const arr = new Uint8Array(bytes);
let hex = '';
for (let i = 0; i < arr.length; i += 1) {
hex += arr[i].toString(16).padStart(2, '0');
}
return hex;
}
async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return bytesToHex(digest);
}
/**
* Match Python's ``repr(float)`` — integer-valued floats keep the
* trailing ``.0``. Required because the backend's ``submission_hash``
* uses ``repr(float(timestamp))`` and we have to produce the exact
* same canonical string for the SHA-256 to match.
*/
function pythonReprFloat(x: number): string {
if (!Number.isFinite(x)) {
throw new Error(`pythonReprFloat: ${x} is not a finite number`);
}
const s = String(x);
if (Number.isInteger(x) && !s.includes('.') && !s.includes('e')) {
return `${s}.0`;
}
return s;
}
/**
* SHA-256 of canonical evidence content. Mirrors
* ``services/infonet/markets/evidence.py:evidence_content_hash``.
* Excludes node_id — used for cross-author duplicate detection.
*/
export async function evidenceContentHash(args: {
marketId: string;
claimedOutcome: 'yes' | 'no';
evidenceHashes: string[];
sourceDescription: string;
}): Promise<string> {
const sorted = [...(args.evidenceHashes || [])].map(String).sort();
const canonical = [
'evidence_content',
args.marketId,
args.claimedOutcome,
sorted.join(','),
String(args.sourceDescription || '').normalize('NFC'),
].join('|');
return sha256Hex(canonical);
}
/**
* SHA-256 of ``content_hash || node_id || repr(timestamp)``. Mirrors
* ``services/infonet/markets/evidence.py:submission_hash``. Includes
* node_id — used for authorship + chain ordering.
*/
export async function submissionHash(args: {
contentHash: string;
nodeId: string;
timestamp: number;
}): Promise<string> {
const canonical = [
'evidence_submission',
args.contentHash,
args.nodeId,
pythonReprFloat(args.timestamp),
].join('|');
return sha256Hex(canonical);
}
/**
* Build a fully-formed ``evidence_submit`` payload with both
* canonical hashes computed locally. Async because it pulls the
* local node identity and runs WebCrypto SHA-256.
*
* The caller wraps the result with ``signAndAppend``.
*/
export async function buildEvidenceSubmitPayload(args: {
marketId: string;
claimedOutcome: 'yes' | 'no';
evidenceHashes: string[];
sourceDescription: string;
bond: number;
}): Promise<{
event_type: 'evidence_submit';
payload: Record<string, unknown>;
}> {
const meshIdentity = await import('@/mesh/meshIdentity');
const identity = meshIdentity.getNodeIdentity();
if (!identity?.publicKey) {
throw new Error(
'node_identity_not_loaded — open the InfonetTerminal so the local key materializes',
);
}
const nodeId = await meshIdentity.deriveNodeIdFromPublicKey(identity.publicKey);
const timestamp = Date.now() / 1000;
const contentHash = await evidenceContentHash({
marketId: args.marketId,
claimedOutcome: args.claimedOutcome,
evidenceHashes: args.evidenceHashes,
sourceDescription: args.sourceDescription,
});
const subHash = await submissionHash({
contentHash,
nodeId,
timestamp,
});
return {
event_type: 'evidence_submit',
payload: {
market_id: args.marketId,
claimed_outcome: args.claimedOutcome,
evidence_hashes: args.evidenceHashes,
source_description: args.sourceDescription,
evidence_content_hash: contentHash,
submission_hash: subHash,
bond: args.bond,
},
};
}
// ─── Upgrade-hash governance — propose / signal-ready ──────────────────
export function buildUpgradeProposePayload(args: {
proposalId: string;
releaseHash: string;
releaseDescription: string;
targetProtocolVersion: string;
releaseUrl?: string;
compatibilityNotes?: string;
}): { event_type: 'upgrade_propose'; payload: Record<string, unknown> } {
return {
event_type: 'upgrade_propose',
payload: {
proposal_id: args.proposalId,
release_hash: args.releaseHash,
release_description: args.releaseDescription,
target_protocol_version: args.targetProtocolVersion,
...(args.releaseUrl ? { release_url: args.releaseUrl } : {}),
...(args.compatibilityNotes ? { compatibility_notes: args.compatibilityNotes } : {}),
},
};
}
export function buildUpgradeSignalReadyPayload(
proposalId: string,
releaseHash: string,
): { event_type: 'upgrade_signal_ready'; payload: Record<string, unknown> } {
return {
event_type: 'upgrade_signal_ready',
payload: { proposal_id: proposalId, release_hash: releaseHash },
};
}
+23 -16
View File
@@ -1,5 +1,5 @@
import { currentMailboxEpoch } from '@/mesh/meshMailbox';
import { allDmPeerIds } from '@/mesh/meshDmConsent';
import { mailboxPeerRefs } from '@/mesh/meshDmConsent';
import { deriveSharedSecret, getStoredNodeDescriptor, type Contact } from '@/mesh/meshIdentity';
import {
deriveWormholeDeadDropTokenPair,
@@ -21,21 +21,27 @@ export async function hmacSha256(keyBytes: ArrayBuffer, message: string): Promis
return crypto.subtle.sign('HMAC', key, data);
}
function contactContext(peerId: string): string | null {
function contactContext(peerRef: string): string | null {
const identity = getStoredNodeDescriptor();
if (!identity) return null;
const ids = [identity.nodeId, peerId].sort().join('|');
const ids = [identity.nodeId, peerRef].sort().join('|');
return ids;
}
export async function deadDropToken(peerId: string, peerDhPub: string, epoch?: number): Promise<string> {
export async function deadDropToken(
peerId: string,
peerDhPub: string,
epoch?: number,
peerRef?: string,
): Promise<string> {
const resolvedPeerRef = String(peerRef || peerId || '').trim();
if (await isWormholeReady()) {
const pair = await deriveWormholeDeadDropTokenPair(peerId, peerDhPub).catch(() => null);
const pair = await deriveWormholeDeadDropTokenPair(peerId, peerDhPub, resolvedPeerRef).catch(() => null);
if (pair?.ok) {
return epoch === pair.epoch - 1 ? pair.previous : pair.current;
}
}
const ctx = contactContext(peerId);
const ctx = contactContext(resolvedPeerRef);
if (!ctx) return '';
const bucket = typeof epoch === 'number' ? epoch : currentMailboxEpoch();
const secret = await deriveSharedSecret(peerDhPub);
@@ -46,10 +52,11 @@ export async function deadDropToken(peerId: string, peerDhPub: string, epoch?: n
export async function deadDropTokenPair(
peerId: string,
peerDhPub: string,
peerRef?: string,
): Promise<{ current: string; previous: string; epoch: number }> {
const epoch = currentMailboxEpoch();
const current = await deadDropToken(peerId, peerDhPub, epoch);
const previous = await deadDropToken(peerId, peerDhPub, epoch - 1);
const current = await deadDropToken(peerId, peerDhPub, epoch, peerRef);
const previous = await deadDropToken(peerId, peerDhPub, epoch - 1, peerRef);
return { current, previous, epoch };
}
@@ -60,12 +67,12 @@ export async function deadDropTokensForContacts(
if (await isWormholeReady()) {
const items = Object.entries(contacts)
.filter(([_, contact]) => Boolean(contact?.dhPubKey) && !contact.blocked)
.flatMap(([peerId, contact]) =>
allDmPeerIds(peerId, contact).map((candidateId) => ({
peer_id: candidateId,
peer_dh_pub: String(contact?.dhPubKey || ''),
})),
)
.map(([peerId, contact]) => ({
peer_id: peerId,
peer_dh_pub: String(contact?.dhPubKey || ''),
peer_refs: mailboxPeerRefs(peerId, contact),
}))
.filter((item) => item.peer_refs.length > 0)
.slice(0, limit);
if (items.length > 0) {
const batch = await deriveWormholeDeadDropTokens(items, limit).catch(() => null);
@@ -87,9 +94,9 @@ export async function deadDropTokensForContacts(
const tokens: string[] = [];
for (const [peerId, contact] of Object.entries(contacts)) {
if (!contact?.dhPubKey || contact.blocked) continue;
for (const candidateId of allDmPeerIds(peerId, contact)) {
for (const candidateId of mailboxPeerRefs(peerId, contact)) {
try {
const pair = await deadDropTokenPair(candidateId, contact.dhPubKey);
const pair = await deadDropTokenPair(peerId, contact.dhPubKey, candidateId);
if (pair.current) tokens.push(pair.current);
if (pair.previous) tokens.push(pair.previous);
if (tokens.length >= limit) break;
+43 -5
View File
@@ -35,6 +35,7 @@ export type MailboxClaim = { type: 'self' | 'requests' | 'shared'; token?: strin
export type DmPublicKeyBundle = {
ok: boolean;
agent_id: string;
lookup_mode?: string;
dh_pub_key: string;
dh_algo?: string;
timestamp?: number;
@@ -44,6 +45,11 @@ export type DmPublicKeyBundle = {
protocol_version?: string;
sequence?: number;
bundle_fingerprint?: string;
prekey_transparency_head?: string;
prekey_transparency_size?: number;
prekey_transparency_fingerprint?: string;
witness_count?: number;
witness_latest_at?: number;
};
export type DmMessageEnvelope = {
@@ -63,6 +69,7 @@ export type DmPollResponse = {
ok: boolean;
messages: DmMessageEnvelope[];
count: number;
has_more?: boolean;
detail?: string;
};
@@ -77,6 +84,9 @@ export type DmSendResponse = {
msg_id?: string;
detail?: string;
transport?: 'reticulum' | 'relay';
queued?: boolean;
outbox_id?: string;
private_transport_pending?: boolean;
};
export type DmSendRequest = {
@@ -106,6 +116,7 @@ const MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED =
export const MAILBOX_SHARED_CLAIM_SHAPE_VERSION = MAILBOX_SHARED_CLAIM_EXPERIMENT_ENABLED
? 'rfc2a-bucketed-v1'
: 'legacy-floor-v1';
const PRIVATE_DM_TRANSPORT_LOCK = 'private_strong';
const senderTokenCache = new Map<string, Array<{ sender_token: string; expires_at: number }>>();
let bundleFingerprintCache = '';
@@ -322,7 +333,12 @@ export async function ensureRegisteredDmKey(
}
const timestamp = Math.floor(Date.now() / 1000);
const payload = { dh_pub_key: dhPubKey, dh_algo: dhAlgo, timestamp };
const payload = {
dh_pub_key: dhPubKey,
dh_algo: dhAlgo,
timestamp,
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
};
const valid = validateEventPayload('dm_key', payload as Record<string, JsonValue>);
if (!valid.ok) return { ok: false, detail: valid.reason };
const sequence = nextSequence();
@@ -335,6 +351,7 @@ export async function ensureRegisteredDmKey(
dh_pub_key: dhPubKey,
dh_algo: dhAlgo,
timestamp,
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
public_key: signed.context.publicKey,
public_key_algo: signed.context.publicKeyAlgo,
signature: signed.signature,
@@ -359,8 +376,22 @@ export async function ensureRegisteredDmKey(
export async function fetchDmPublicKey(
apiBase: string,
agentId: string,
lookupToken?: string,
options?: { allowLegacyAgentId?: boolean },
): Promise<DmPublicKeyBundle | null> {
const res = await fetch(`${apiBase}/api/mesh/dm/pubkey?agent_id=${encodeURIComponent(agentId)}`);
const normalizedLookupToken = String(lookupToken || '').trim();
const normalizedAgentId = String(agentId || '').trim();
if (!normalizedLookupToken && !options?.allowLegacyAgentId) {
return null;
}
const params = new URLSearchParams();
if (normalizedLookupToken) {
params.set('lookup_token', normalizedLookupToken);
}
if (normalizedAgentId && !normalizedLookupToken && options?.allowLegacyAgentId) {
params.set('agent_id', normalizedAgentId);
}
const res = await fetch(`${apiBase}/api/mesh/dm/pubkey?${params.toString()}`);
const data = await res.json();
return data.ok ? data : null;
}
@@ -435,8 +466,11 @@ async function buildBucketedSharedMailboxClaims(sharedTokens: string[]): Promise
return interleaveSharedClaims(sharedTokens, decoyTokens);
}
export async function buildMailboxClaims(contacts: Record<string, Contact>): Promise<MailboxClaim[]> {
const identity = getNodeIdentity();
export async function buildMailboxClaims(
contacts: Record<string, Contact>,
identityOverride?: Pick<NodeIdentity, 'nodeId'> | null,
): Promise<MailboxClaim[]> {
const identity = identityOverride?.nodeId ? identityOverride : getNodeIdentity();
if (!identity?.nodeId) {
throw new Error('No local identity available for mailbox claims');
}
@@ -468,6 +502,7 @@ async function signedMailboxRequest(
})),
timestamp: Math.floor(Date.now() / 1000),
nonce: randomHex(16),
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
};
const valid = validateEventPayload(eventType, payload as Record<string, JsonValue>);
if (!valid.ok) {
@@ -483,6 +518,7 @@ async function signedMailboxRequest(
mailbox_claims: payload.mailbox_claims,
timestamp: payload.timestamp,
nonce: payload.nonce,
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
public_key: signed.context.publicKey,
public_key_algo: signed.context.publicKeyAlgo,
signature: signed.signature,
@@ -583,6 +619,7 @@ export async function sendDmMessage(request: DmSendRequest): Promise<DmSendRespo
msg_id: request.msgId,
timestamp: request.timestamp,
format: payloadFormat,
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
};
if (request.sessionWelcome) {
dmPayload.session_welcome = request.sessionWelcome;
@@ -599,7 +636,7 @@ export async function sendDmMessage(request: DmSendRequest): Promise<DmSendRespo
}
const sequence = nextSequence();
const signed = await signMeshEvent('dm_message', dmPayload, sequence);
if (senderSeal && signed.context.source === 'wormhole') {
if (request.deliveryClass === 'request' || request.deliveryClass === 'shared' || senderSeal) {
try {
senderToken = takeCachedSenderToken(
request.recipientId,
@@ -636,6 +673,7 @@ export async function sendDmMessage(request: DmSendRequest): Promise<DmSendRespo
recipient_token: request.recipientToken || '',
ciphertext: request.ciphertext,
format: payloadFormat,
transport_lock: PRIVATE_DM_TRANSPORT_LOCK,
session_welcome: request.sessionWelcome || '',
sender_seal: senderSeal,
relay_salt: relaySalt,
+20 -3
View File
@@ -163,7 +163,7 @@ export function parseAliasRotateMessage(
export function mergeAliasHistory(
aliases: Array<string | undefined | null>,
limit: number = 3,
limit: number = 2,
): string[] {
const unique = new Set<string>();
const ordered: string[] = [];
@@ -199,10 +199,27 @@ export function allDmPeerIds(
const pendingAlias = String(contact?.pendingSharedAlias || '').trim();
if (pendingAlias) unique.add(pendingAlias);
if (sharedAlias) unique.add(sharedAlias);
for (const alias of contact?.previousSharedAliases || []) {
for (const alias of (contact?.previousSharedAliases || []).slice(0, 2)) {
const value = String(alias || '').trim();
if (value) unique.add(value);
if (value && unique.size < 4) unique.add(value);
}
if (peerId) unique.add(peerId);
return Array.from(unique);
}
export function mailboxPeerRefs(
peerId: string,
contact?: ContactAliasLike | null,
): string[] {
const unique = new Set<string>();
const sharedAlias = String(contact?.sharedAlias || '').trim();
const pendingAlias = String(contact?.pendingSharedAlias || '').trim();
if (sharedAlias) unique.add(sharedAlias);
if (pendingAlias) unique.add(pendingAlias);
for (const alias of (contact?.previousSharedAliases || []).slice(0, 2)) {
const value = String(alias || '').trim();
if (value && unique.size < 4) unique.add(value);
}
if (unique.size === 0 && peerId) unique.add(peerId);
return Array.from(unique);
}
+407
View File
@@ -0,0 +1,407 @@
/// <reference lib="webworker" />
import initPrivacyCore, {
wasm_gate_decrypt,
wasm_gate_encrypt,
wasm_gate_export_state,
wasm_gate_import_state,
wasm_release_group,
wasm_release_identity,
wasm_reset_all_state,
} from './privacyCoreWasm/privacy_core';
import {
clearWorkerGateStates,
deleteWorkerGateState,
readWorkerGateState,
writeWorkerGateState,
type WorkerGateStateMember,
type WorkerGateStateSnapshot,
} from './meshGateWorkerVault';
type WorkerRequest =
| { id: string; action: 'supported' }
| { id: string; action: 'adopt'; snapshot: WorkerGateStateSnapshot }
| { id: string; action: 'compose'; gateId: string; plaintext: string; replyTo?: string }
| {
id: string;
action: 'decryptBatch';
messages: Array<{ gate_id: string; epoch?: number; ciphertext: string }>;
}
| { id: string; action: 'forget'; gateId?: string };
type WorkerResponse = { id: string; ok: boolean; result?: unknown; error?: string };
type GateStateImportMapping = {
identities: Record<string, number>;
groups: Record<string, number>;
};
type ImportedGateState = {
snapshot: WorkerGateStateSnapshot;
identityHandles: number[];
groupHandles: number[];
activeGroupHandle: number;
members: WorkerGateStateMember[];
};
const GATE_BUCKETS = [192, 384, 768, 1536, 3072, 6144];
let wasmReady: Promise<void> | null = null;
const gateStateCache = new Map<string, ImportedGateState>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(String(value || '').trim());
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function padGateCiphertext(raw: Uint8Array): Uint8Array {
const prefixed = new Uint8Array(raw.length + 2);
const len = Math.min(raw.length, 0xffff);
prefixed[0] = (len >> 8) & 0xff;
prefixed[1] = len & 0xff;
prefixed.set(raw, 2);
for (const bucket of GATE_BUCKETS) {
if (prefixed.length <= bucket) {
const out = new Uint8Array(bucket);
out.set(prefixed);
return out;
}
}
const lastBucket = GATE_BUCKETS[GATE_BUCKETS.length - 1] || 6144;
const target = Math.ceil(prefixed.length / lastBucket) * lastBucket;
const out = new Uint8Array(target);
out.set(prefixed);
return out;
}
function unpadGateCiphertext(padded: Uint8Array): Uint8Array {
if (padded.length < 2) return padded;
const originalLen = ((padded[0] << 8) | padded[1]) >>> 0;
if (originalLen <= 0 || originalLen + 2 > padded.length) {
return padded;
}
return padded.slice(2, 2 + originalLen);
}
function encodeGateCiphertext(raw: Uint8Array): string {
return bytesToBase64(padGateCiphertext(raw));
}
function decodeGateCiphertext(ciphertextB64: string): Uint8Array {
return unpadGateCiphertext(base64ToBytes(ciphertextB64));
}
function encodeGatePlaintext(plaintext: string, epoch: number, replyTo: string = ''): Uint8Array {
const normalizedReplyTo = String(replyTo || '').trim();
return new TextEncoder().encode(
JSON.stringify({
m: plaintext,
e: epoch,
...(normalizedReplyTo ? { r: normalizedReplyTo } : {}),
}),
);
}
function decodeGatePlaintext(ciphertextOpen: Uint8Array, fallbackEpoch: number): {
plaintext: string;
epoch: number;
reply_to: string;
} {
const raw = new TextDecoder().decode(ciphertextOpen);
try {
const parsed = JSON.parse(raw) as { m?: string; e?: number; r?: string };
return {
plaintext: typeof parsed?.m === 'string' ? parsed.m : raw,
epoch: Number.isFinite(parsed?.e) ? Number(parsed.e) : fallbackEpoch,
reply_to: typeof parsed?.r === 'string' ? parsed.r : '',
};
} catch {
return { plaintext: raw, epoch: fallbackEpoch, reply_to: '' };
}
}
function generateGateNonce(): string {
const bytes = new Uint8Array(12);
crypto.getRandomValues(bytes);
return bytesToBase64(bytes);
}
function gateMemberMatchesActive(
snapshot: WorkerGateStateSnapshot,
member: WorkerGateStateMember,
): boolean {
const activeScope = String(snapshot.active_identity_scope || '').trim().toLowerCase();
if (activeScope === 'persona') {
const activePersonaId = String(snapshot.active_persona_id || '').trim();
return Boolean(activePersonaId) && String(member.persona_id || '').trim() === activePersonaId;
}
const activeNodeId = String(snapshot.active_node_id || '').trim();
return (
Boolean(activeNodeId) &&
String(member.node_id || '').trim() === activeNodeId &&
String(member.identity_scope || '').trim().toLowerCase() === 'anonymous'
);
}
async function ensureWasm(): Promise<void> {
if (!wasmReady) {
wasmReady = initPrivacyCore().then(() => undefined);
}
await wasmReady;
}
function releaseImportedState(imported: ImportedGateState): void {
for (const groupHandle of imported.groupHandles) {
try {
wasm_release_group(BigInt(groupHandle));
} catch {
/* ignore */
}
}
for (const identityHandle of imported.identityHandles) {
try {
wasm_release_identity(BigInt(identityHandle));
} catch {
/* ignore */
}
}
}
function cacheImportedState(imported: ImportedGateState): void {
const gateId = normalizeGateId(imported.snapshot.gate_id);
const previous = gateStateCache.get(gateId);
if (previous) {
releaseImportedState(previous);
}
gateStateCache.set(gateId, imported);
}
function parseImportMapping(json: string): GateStateImportMapping {
const parsed = JSON.parse(json) as Partial<GateStateImportMapping>;
return {
identities: parsed.identities || {},
groups: parsed.groups || {},
};
}
async function importSnapshot(snapshot: WorkerGateStateSnapshot): Promise<ImportedGateState> {
await ensureWasm();
const blob = base64ToBytes(snapshot.rust_state_blob_b64);
const mapping = parseImportMapping(wasm_gate_import_state(blob));
const remappedMembers = (snapshot.members || []).map((member) => {
const key = String(member.group_handle);
const mapped = Number(mapping.groups[key] || 0);
if (!mapped) {
throw new Error(`browser_gate_state_mapping_missing_group:${key}`);
}
return {
...member,
group_handle: mapped,
};
});
const activeMember = remappedMembers.find((member) => gateMemberMatchesActive(snapshot, member));
if (!activeMember?.group_handle) {
throw new Error('browser_gate_state_active_member_missing');
}
return {
snapshot: {
...snapshot,
gate_id: normalizeGateId(snapshot.gate_id),
members: remappedMembers,
},
identityHandles: Object.values(mapping.identities || {}).map((value) => Number(value)),
groupHandles: Array.from(
new Set(remappedMembers.map((member) => Number(member.group_handle)).filter(Boolean)),
),
activeGroupHandle: Number(activeMember.group_handle),
members: remappedMembers,
};
}
async function persistImportedState(imported: ImportedGateState): Promise<void> {
await ensureWasm();
const blob = wasm_gate_export_state(
JSON.stringify(imported.identityHandles),
JSON.stringify(imported.groupHandles),
);
const snapshot: WorkerGateStateSnapshot = {
...imported.snapshot,
gate_id: normalizeGateId(imported.snapshot.gate_id),
rust_state_blob_b64: bytesToBase64(blob),
members: imported.members,
};
imported.snapshot = snapshot;
await writeWorkerGateState(snapshot);
}
async function ensureImportedGateState(gateId: string): Promise<ImportedGateState> {
const normalized = normalizeGateId(gateId);
const cached = gateStateCache.get(normalized);
if (cached) return cached;
const snapshot = await readWorkerGateState(normalized);
if (!snapshot) {
throw new Error(`browser_gate_state_resync_required:${normalized}`);
}
const imported = await importSnapshot(snapshot);
cacheImportedState(imported);
return imported;
}
async function adoptGateState(snapshot: WorkerGateStateSnapshot): Promise<WorkerGateStateSnapshot> {
const imported = await importSnapshot(snapshot);
cacheImportedState(imported);
await persistImportedState(imported);
return imported.snapshot;
}
async function forgetGateState(gateId?: string): Promise<void> {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
for (const imported of gateStateCache.values()) {
releaseImportedState(imported);
}
gateStateCache.clear();
try {
wasm_reset_all_state();
} catch {
/* ignore */
}
await clearWorkerGateStates();
return;
}
const existing = gateStateCache.get(normalized);
if (existing) {
releaseImportedState(existing);
gateStateCache.delete(normalized);
}
await deleteWorkerGateState(normalized);
}
async function composeGateCiphertext(gateId: string, plaintext: string, replyTo: string = ''): Promise<{
gate_id: string;
epoch: number;
ciphertext: string;
nonce: string;
}> {
const imported = await ensureImportedGateState(gateId);
const encodedPlaintext = encodeGatePlaintext(
plaintext,
Number(imported.snapshot.epoch || 0),
replyTo,
);
const rawCiphertext = wasm_gate_encrypt(BigInt(imported.activeGroupHandle), encodedPlaintext);
await persistImportedState(imported);
return {
gate_id: normalizeGateId(gateId),
epoch: Number(imported.snapshot.epoch || 0),
ciphertext: encodeGateCiphertext(rawCiphertext),
nonce: generateGateNonce(),
};
}
async function decryptGateBatch(
messages: Array<{ gate_id: string; epoch?: number; ciphertext: string }>,
): Promise<Array<Record<string, unknown>>> {
const results: Array<Record<string, unknown>> = [];
for (const message of messages) {
const gateId = normalizeGateId(message.gate_id);
try {
const imported = await ensureImportedGateState(gateId);
const requestedEpoch = Number(message.epoch || 0);
if (requestedEpoch > 0 && requestedEpoch > Number(imported.snapshot.epoch || 0)) {
results.push({
ok: false,
detail: `browser_gate_state_resync_required:${gateId}`,
gate_id: gateId,
});
continue;
}
const ciphertext = decodeGateCiphertext(message.ciphertext);
let opened: Uint8Array | null = null;
for (const groupHandle of imported.groupHandles) {
try {
opened = wasm_gate_decrypt(BigInt(groupHandle), ciphertext);
break;
} catch {
/* keep trying remapped members */
}
}
if (!opened) {
results.push({
ok: false,
detail: 'gate_mls_decrypt_failed',
gate_id: gateId,
});
continue;
}
const decoded = decodeGatePlaintext(opened, requestedEpoch || Number(imported.snapshot.epoch || 0));
await persistImportedState(imported);
results.push({
ok: true,
gate_id: gateId,
epoch: decoded.epoch,
plaintext: decoded.plaintext,
reply_to: decoded.reply_to,
identity_scope: 'browser_privacy_core',
});
} catch (error) {
results.push({
ok: false,
detail: error instanceof Error ? error.message : 'browser_gate_crypto_error',
gate_id: gateId,
});
}
}
return results;
}
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const msg = event.data;
const respond = (payload: WorkerResponse) => postMessage(payload);
try {
switch (msg.action) {
case 'supported':
await ensureWasm();
respond({ id: msg.id, ok: true, result: true });
return;
case 'adopt': {
const snapshot = await adoptGateState(msg.snapshot);
respond({ id: msg.id, ok: true, result: snapshot });
return;
}
case 'compose': {
const result = await composeGateCiphertext(msg.gateId, msg.plaintext, msg.replyTo || '');
respond({ id: msg.id, ok: true, result });
return;
}
case 'decryptBatch': {
const results = await decryptGateBatch(msg.messages);
respond({ id: msg.id, ok: true, result: results });
return;
}
case 'forget':
await forgetGateState(msg.gateId);
respond({ id: msg.id, ok: true, result: true });
return;
default: {
const unsupported = msg as { id?: string };
respond({ id: unsupported.id || '', ok: false, error: 'unsupported_gate_worker_action' });
}
}
} catch (error) {
const message = error instanceof Error ? error.message : 'worker_error';
respond({ id: msg.id, ok: false, error: message || 'worker_error' });
}
};
+410
View File
@@ -0,0 +1,410 @@
import initPrivacyCore, {
wasm_gate_decrypt,
wasm_gate_encrypt,
wasm_gate_export_state,
wasm_gate_import_state,
wasm_release_group,
wasm_release_identity,
wasm_reset_all_state,
} from './privacyCoreWasm/privacy_core';
import {
clearWorkerGateStates,
deleteWorkerGateState,
probeWorkerGateVaultAvailability,
readWorkerGateState,
writeWorkerGateState,
type WorkerGateStateMember,
type WorkerGateStateSnapshot,
} from './meshGateWorkerVault';
export type LocalGateComposeResult = {
gate_id: string;
epoch: number;
ciphertext: string;
nonce: string;
};
export type LocalGateDecryptResult = {
ok: boolean;
gate_id: string;
epoch?: number;
plaintext?: string;
reply_to?: string;
detail?: string;
identity_scope?: string;
};
export type InlineGateCryptoSupport = {
supported: boolean;
reason: string;
};
type GateStateImportMapping = {
identities: Record<string, number>;
groups: Record<string, number>;
};
type ImportedGateState = {
snapshot: WorkerGateStateSnapshot;
identityHandles: number[];
groupHandles: number[];
activeGroupHandle: number;
members: WorkerGateStateMember[];
};
const GATE_BUCKETS = [192, 384, 768, 1536, 3072, 6144];
let wasmReady: Promise<void> | null = null;
const gateStateCache = new Map<string, ImportedGateState>();
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function bytesToBase64(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes));
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(String(value || '').trim());
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
out[i] = binary.charCodeAt(i);
}
return out;
}
function padGateCiphertext(raw: Uint8Array): Uint8Array {
const prefixed = new Uint8Array(raw.length + 2);
const len = Math.min(raw.length, 0xffff);
prefixed[0] = (len >> 8) & 0xff;
prefixed[1] = len & 0xff;
prefixed.set(raw, 2);
for (const bucket of GATE_BUCKETS) {
if (prefixed.length <= bucket) {
const out = new Uint8Array(bucket);
out.set(prefixed);
return out;
}
}
const lastBucket = GATE_BUCKETS[GATE_BUCKETS.length - 1] || 6144;
const target = Math.ceil(prefixed.length / lastBucket) * lastBucket;
const out = new Uint8Array(target);
out.set(prefixed);
return out;
}
function unpadGateCiphertext(padded: Uint8Array): Uint8Array {
if (padded.length < 2) return padded;
const originalLen = ((padded[0] << 8) | padded[1]) >>> 0;
if (originalLen <= 0 || originalLen + 2 > padded.length) {
return padded;
}
return padded.slice(2, 2 + originalLen);
}
function encodeGateCiphertext(raw: Uint8Array): string {
return bytesToBase64(padGateCiphertext(raw));
}
function decodeGateCiphertext(ciphertextB64: string): Uint8Array {
return unpadGateCiphertext(base64ToBytes(ciphertextB64));
}
function encodeGatePlaintext(plaintext: string, epoch: number, replyTo: string = ''): Uint8Array {
const normalizedReplyTo = String(replyTo || '').trim();
return new TextEncoder().encode(
JSON.stringify({
m: plaintext,
e: epoch,
...(normalizedReplyTo ? { r: normalizedReplyTo } : {}),
}),
);
}
function decodeGatePlaintext(ciphertextOpen: Uint8Array, fallbackEpoch: number): {
plaintext: string;
epoch: number;
reply_to: string;
} {
const raw = new TextDecoder().decode(ciphertextOpen);
try {
const parsed = JSON.parse(raw) as { m?: string; e?: number; r?: string };
return {
plaintext: typeof parsed?.m === 'string' ? parsed.m : raw,
epoch: Number.isFinite(parsed?.e) ? Number(parsed.e) : fallbackEpoch,
reply_to: typeof parsed?.r === 'string' ? parsed.r : '',
};
} catch {
return { plaintext: raw, epoch: fallbackEpoch, reply_to: '' };
}
}
function generateGateNonce(): string {
const bytes = new Uint8Array(12);
crypto.getRandomValues(bytes);
return bytesToBase64(bytes);
}
function gateMemberMatchesActive(
snapshot: WorkerGateStateSnapshot,
member: WorkerGateStateMember,
): boolean {
const activeScope = String(snapshot.active_identity_scope || '').trim().toLowerCase();
if (activeScope === 'persona') {
const activePersonaId = String(snapshot.active_persona_id || '').trim();
return Boolean(activePersonaId) && String(member.persona_id || '').trim() === activePersonaId;
}
const activeNodeId = String(snapshot.active_node_id || '').trim();
return (
Boolean(activeNodeId) &&
String(member.node_id || '').trim() === activeNodeId &&
String(member.identity_scope || '').trim().toLowerCase() === 'anonymous'
);
}
async function ensureWasm(): Promise<void> {
if (!wasmReady) {
wasmReady = initPrivacyCore().then(() => undefined);
}
await wasmReady;
}
function releaseImportedState(imported: ImportedGateState): void {
for (const groupHandle of imported.groupHandles) {
try {
wasm_release_group(BigInt(groupHandle));
} catch {
/* ignore */
}
}
for (const identityHandle of imported.identityHandles) {
try {
wasm_release_identity(BigInt(identityHandle));
} catch {
/* ignore */
}
}
}
function cacheImportedState(imported: ImportedGateState): void {
const gateId = normalizeGateId(imported.snapshot.gate_id);
const previous = gateStateCache.get(gateId);
if (previous) {
releaseImportedState(previous);
}
gateStateCache.set(gateId, imported);
}
function parseImportMapping(json: string): GateStateImportMapping {
const parsed = JSON.parse(json) as Partial<GateStateImportMapping>;
return {
identities: parsed.identities || {},
groups: parsed.groups || {},
};
}
async function importSnapshot(snapshot: WorkerGateStateSnapshot): Promise<ImportedGateState> {
await ensureWasm();
const blob = base64ToBytes(snapshot.rust_state_blob_b64);
const mapping = parseImportMapping(wasm_gate_import_state(blob));
const remappedMembers = (snapshot.members || []).map((member) => {
const key = String(member.group_handle);
const mapped = Number(mapping.groups[key] || 0);
if (!mapped) {
throw new Error(`browser_gate_state_mapping_missing_group:${key}`);
}
return {
...member,
group_handle: mapped,
};
});
const activeMember = remappedMembers.find((member) => gateMemberMatchesActive(snapshot, member));
if (!activeMember?.group_handle) {
throw new Error('browser_gate_state_active_member_missing');
}
return {
snapshot: {
...snapshot,
gate_id: normalizeGateId(snapshot.gate_id),
members: remappedMembers,
},
identityHandles: Object.values(mapping.identities || {}).map((value) => Number(value)),
groupHandles: Array.from(
new Set(remappedMembers.map((member) => Number(member.group_handle)).filter(Boolean)),
),
activeGroupHandle: Number(activeMember.group_handle),
members: remappedMembers,
};
}
async function persistImportedState(imported: ImportedGateState): Promise<void> {
await ensureWasm();
const blob = wasm_gate_export_state(
JSON.stringify(imported.identityHandles),
JSON.stringify(imported.groupHandles),
);
const snapshot: WorkerGateStateSnapshot = {
...imported.snapshot,
gate_id: normalizeGateId(imported.snapshot.gate_id),
rust_state_blob_b64: bytesToBase64(blob),
members: imported.members,
};
imported.snapshot = snapshot;
await writeWorkerGateState(snapshot);
}
async function ensureImportedGateState(gateId: string): Promise<ImportedGateState> {
const normalized = normalizeGateId(gateId);
const cached = gateStateCache.get(normalized);
if (cached) return cached;
const snapshot = await readWorkerGateState(normalized);
if (!snapshot) {
throw new Error(`browser_gate_state_resync_required:${normalized}`);
}
const imported = await importSnapshot(snapshot);
cacheImportedState(imported);
return imported;
}
export async function isInlineGateCryptoSupported(): Promise<boolean> {
const support = await probeInlineGateCryptoSupport();
return support.supported;
}
export async function probeInlineGateCryptoSupport(): Promise<InlineGateCryptoSupport> {
if (
typeof window === 'undefined' ||
typeof crypto === 'undefined'
) {
return { supported: false, reason: 'browser_runtime_unavailable' };
}
if (!crypto?.subtle) {
return { supported: false, reason: 'browser_gate_webcrypto_unavailable' };
}
if (typeof indexedDB === 'undefined') {
return { supported: false, reason: 'browser_gate_indexeddb_unavailable' };
}
const vault = await probeWorkerGateVaultAvailability();
if (!vault.ok) {
return { supported: false, reason: vault.reason || 'browser_gate_storage_unavailable' };
}
try {
await ensureWasm();
return { supported: true, reason: '' };
} catch (error) {
return {
supported: false,
reason:
(error instanceof Error ? error.message : String(error || '')).trim() ||
'browser_gate_wasm_unavailable',
};
}
}
export async function adoptInlineGateState(snapshot: WorkerGateStateSnapshot): Promise<WorkerGateStateSnapshot> {
const imported = await importSnapshot(snapshot);
cacheImportedState(imported);
await persistImportedState(imported);
return imported.snapshot;
}
export async function composeInlineGateMessage(
gateId: string,
plaintext: string,
replyTo: string = '',
): Promise<LocalGateComposeResult> {
const imported = await ensureImportedGateState(gateId);
const encodedPlaintext = encodeGatePlaintext(
plaintext,
Number(imported.snapshot.epoch || 0),
replyTo,
);
const rawCiphertext = wasm_gate_encrypt(BigInt(imported.activeGroupHandle), encodedPlaintext);
await persistImportedState(imported);
return {
gate_id: normalizeGateId(gateId),
epoch: Number(imported.snapshot.epoch || 0),
ciphertext: encodeGateCiphertext(rawCiphertext),
nonce: generateGateNonce(),
};
}
export async function decryptInlineGateMessages(
messages: Array<{ gate_id: string; epoch?: number; ciphertext: string }>,
): Promise<LocalGateDecryptResult[]> {
const results: LocalGateDecryptResult[] = [];
for (const message of messages) {
const gateId = normalizeGateId(message.gate_id);
try {
const imported = await ensureImportedGateState(gateId);
const requestedEpoch = Number(message.epoch || 0);
if (requestedEpoch > 0 && requestedEpoch > Number(imported.snapshot.epoch || 0)) {
results.push({
ok: false,
detail: `browser_gate_state_resync_required:${gateId}`,
gate_id: gateId,
});
continue;
}
const ciphertext = decodeGateCiphertext(message.ciphertext);
let opened: Uint8Array | null = null;
for (const groupHandle of imported.groupHandles) {
try {
opened = wasm_gate_decrypt(BigInt(groupHandle), ciphertext);
break;
} catch {
/* keep trying remapped members */
}
}
if (!opened) {
results.push({
ok: false,
detail: 'gate_mls_decrypt_failed',
gate_id: gateId,
});
continue;
}
const decoded = decodeGatePlaintext(opened, requestedEpoch || Number(imported.snapshot.epoch || 0));
await persistImportedState(imported);
results.push({
ok: true,
gate_id: gateId,
epoch: decoded.epoch,
plaintext: decoded.plaintext,
reply_to: decoded.reply_to,
identity_scope: 'browser_privacy_core',
});
} catch (error) {
results.push({
ok: false,
detail: error instanceof Error ? error.message : 'browser_gate_crypto_error',
gate_id: gateId,
});
}
}
return results;
}
export async function forgetInlineGateState(gateId?: string): Promise<void> {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
for (const imported of gateStateCache.values()) {
releaseImportedState(imported);
}
gateStateCache.clear();
try {
wasm_reset_all_state();
} catch {
/* ignore */
}
await clearWorkerGateStates();
return;
}
const existing = gateStateCache.get(normalized);
if (existing) {
releaseImportedState(existing);
gateStateCache.delete(normalized);
}
await deleteWorkerGateState(normalized);
}
+888
View File
@@ -0,0 +1,888 @@
import { controlPlaneJson } from '@/lib/controlPlane';
import type { WorkerGateStateSnapshot } from '@/mesh/meshGateWorkerVault';
import type {
InlineGateCryptoSupport,
LocalGateComposeResult,
LocalGateDecryptResult,
} from '@/mesh/meshGateLocalRuntime';
type WorkerRequest =
| { id: string; action: 'supported' }
| { id: string; action: 'adopt'; snapshot: WorkerGateStateSnapshot }
| { id: string; action: 'compose'; gateId: string; plaintext: string; replyTo?: string }
| {
id: string;
action: 'decryptBatch';
messages: Array<{ gate_id: string; epoch?: number; ciphertext: string }>;
}
| { id: string; action: 'forget'; gateId?: string };
type WorkerResponse = { id: string; ok: boolean; result?: unknown; error?: string };
type WorkerRequestPayload = WorkerRequest extends infer Request
? Request extends WorkerRequest
? Omit<Request, 'id'>
: never
: never;
type BrowserGateComposeResult = {
gate_id: string;
epoch: number;
ciphertext: string;
nonce: string;
};
type BrowserGateDecryptResult = {
ok: boolean;
gate_id: string;
epoch?: number;
plaintext?: string;
reply_to?: string;
detail?: string;
identity_scope?: string;
};
type BrowserGateCryptoAction = 'compose' | 'post' | 'decrypt';
type BrowserGateRuntimeMode = 'worker' | 'inline';
type BrowserGateLocalRuntimeMode = BrowserGateRuntimeMode | 'unavailable' | 'unknown';
type BrowserGateLocalRuntimeHealth = 'active' | 'degraded' | 'unavailable' | 'unknown';
type BrowserGateSelfEchoEntry = {
plaintext: string;
replyTo: string;
epoch: number;
cachedAt: number;
};
type SignedGateEnvelope = {
ok: boolean;
gate_id: string;
identity_scope?: string;
sender_id: string;
public_key: string;
public_key_algo: string;
protocol_version: string;
sequence: number;
signature: string;
epoch: number;
ciphertext: string;
nonce: string;
sender_ref: string;
format: string;
timestamp?: number;
gate_envelope?: string;
envelope_hash?: string;
reply_to?: string;
detail?: string;
};
let worker: Worker | null = null;
let reqCounter = 0;
let browserGateCryptoSupport: Promise<boolean> | null = null;
let browserGateCryptoSupportReason = '';
let browserGateRuntimeMode: BrowserGateRuntimeMode | null = null;
let browserGateInlineRuntimePromise: Promise<typeof import('./meshGateLocalRuntime')> | null = null;
const pending = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const browserGateCryptoFailureReasons = new Map<string, string>();
const browserGateStateSyncFreshUntil = new Map<string, number>();
const BROWSER_GATE_STATE_SYNC_TTL_MS = 15_000;
const browserGateSelfEchoCache = new Map<string, BrowserGateSelfEchoEntry>();
const BROWSER_GATE_SELF_ECHO_TTL_MS = 5 * 60_000;
const BROWSER_GATE_SELF_ECHO_MAX = 256;
const GATE_LOCAL_RUNTIME_EVENT = 'sb:gate-local-runtime';
export interface BrowserGateLocalRuntimeStatus {
mode: BrowserGateLocalRuntimeMode;
health: BrowserGateLocalRuntimeHealth;
reason: string;
updatedAt: number;
}
let browserGateLocalRuntimeStatus: BrowserGateLocalRuntimeStatus = {
mode: 'unknown',
health: 'unknown',
reason: '',
updatedAt: 0,
};
function normalizeGateId(gateId: string): string {
return String(gateId || '').trim().toLowerCase();
}
function dispatchBrowserGateLocalRuntimeStatus(): void {
if (typeof window === 'undefined') return;
try {
window.dispatchEvent(
new CustomEvent(GATE_LOCAL_RUNTIME_EVENT, {
detail: getBrowserGateLocalRuntimeStatus(),
}),
);
} catch {
/* ignore */
}
}
function normalizeBrowserGateRuntimeReason(reason: string): string {
const detail = String(reason || '').trim().toLowerCase();
if (!detail) return 'browser_local_gate_crypto_unavailable';
if (
detail === 'browser_runtime_unavailable' ||
detail === 'browser_local_gate_crypto_unavailable' ||
detail === 'browser_gate_worker_unavailable' ||
detail === 'browser_gate_webcrypto_unavailable' ||
detail === 'browser_gate_indexeddb_unavailable' ||
detail === 'browser_gate_storage_unavailable' ||
detail === 'browser_gate_wasm_unavailable' ||
detail === 'browser_gate_state_active_member_missing' ||
detail === 'worker_gate_wrap_key_missing' ||
detail === 'gate_mls_decrypt_failed' ||
detail === 'gate_sign_failed'
) {
return detail;
}
if (
detail.startsWith('browser_gate_state_resync_required:') ||
detail.startsWith('browser_gate_state_mapping_missing_group:')
) {
return detail;
}
if (detail.includes('indexeddb')) return 'browser_gate_indexeddb_unavailable';
if (detail.includes('database') || detail.includes('idb')) return 'browser_gate_storage_unavailable';
if (detail.includes('webcrypto') || detail.includes('subtlecrypto') || detail.includes('crypto.subtle')) {
return 'browser_gate_webcrypto_unavailable';
}
if (detail.includes('wasm') || detail.includes('privacy_core')) return 'browser_gate_wasm_unavailable';
if (detail.includes('worker')) return 'browser_gate_worker_unavailable';
return detail;
}
function describeBrowserGateLocalRuntimeReason(reason: string): string {
const detail = normalizeBrowserGateRuntimeReason(reason);
if (detail === 'browser_gate_worker_unavailable') return 'worker unavailable';
if (detail === 'browser_gate_webcrypto_unavailable') return 'WebCrypto unavailable';
if (detail === 'browser_gate_indexeddb_unavailable') return 'IndexedDB unavailable';
if (detail === 'browser_gate_storage_unavailable' || detail === 'worker_gate_wrap_key_missing') {
return 'secure storage unavailable';
}
if (detail === 'browser_gate_wasm_unavailable') return 'crypto runtime unavailable';
if (detail.startsWith('browser_gate_state_resync_required:')) return 'state resync required';
if (
detail.startsWith('browser_gate_state_mapping_missing_group:') ||
detail === 'browser_gate_state_active_member_missing'
) {
return 'state incomplete';
}
if (detail === 'gate_mls_decrypt_failed') return 'decrypt failed';
if (detail === 'gate_sign_failed') return 'sign failed';
if (detail === 'browser_runtime_unavailable') return 'runtime unavailable';
return detail || 'runtime unavailable';
}
function setBrowserGateLocalRuntimeStatus(
mode: BrowserGateLocalRuntimeMode,
health: BrowserGateLocalRuntimeHealth,
reason: string = '',
): void {
const normalizedReason = String(reason || '').trim();
if (
browserGateLocalRuntimeStatus.mode === mode &&
browserGateLocalRuntimeStatus.health === health &&
browserGateLocalRuntimeStatus.reason === normalizedReason
) {
return;
}
browserGateLocalRuntimeStatus = {
mode,
health,
reason: normalizedReason,
updatedAt: Date.now(),
};
dispatchBrowserGateLocalRuntimeStatus();
}
function markBrowserGateLocalRuntimeActive(mode: BrowserGateRuntimeMode | null): void {
if (mode === 'worker') {
setBrowserGateLocalRuntimeStatus('worker', 'active');
return;
}
if (mode === 'inline') {
setBrowserGateLocalRuntimeStatus(
'inline',
'active',
normalizeBrowserGateRuntimeReason(browserGateCryptoSupportReason || 'browser_gate_worker_unavailable'),
);
return;
}
setBrowserGateLocalRuntimeStatus('unknown', 'unknown');
}
function markBrowserGateLocalRuntimeUnavailable(reason: string): void {
setBrowserGateLocalRuntimeStatus(
'unavailable',
'unavailable',
normalizeBrowserGateRuntimeReason(reason),
);
}
function markBrowserGateLocalRuntimeDegraded(
reason: string,
preferredMode: BrowserGateRuntimeMode | null = browserGateRuntimeMode,
): void {
const normalizedReason = normalizeBrowserGateRuntimeReason(reason);
if (preferredMode === 'worker' || preferredMode === 'inline') {
setBrowserGateLocalRuntimeStatus(preferredMode, 'degraded', normalizedReason);
return;
}
markBrowserGateLocalRuntimeUnavailable(normalizedReason);
}
export function getBrowserGateLocalRuntimeStatus(): BrowserGateLocalRuntimeStatus {
return { ...browserGateLocalRuntimeStatus };
}
export function getBrowserGateLocalRuntimeEventName(): string {
return GATE_LOCAL_RUNTIME_EVENT;
}
export function describeBrowserGateLocalRuntimeStatus(
status: BrowserGateLocalRuntimeStatus | null | undefined,
): string {
const current = status || browserGateLocalRuntimeStatus;
if (current.mode === 'worker' && current.health === 'active') {
return 'WORKER local gate runtime active';
}
if (current.mode === 'inline' && current.health === 'active') {
return current.reason === 'browser_gate_worker_unavailable'
? 'INLINE local gate runtime active (worker unavailable)'
: 'INLINE local gate runtime active';
}
if ((current.mode === 'worker' || current.mode === 'inline') && current.health === 'degraded') {
return `${current.mode.toUpperCase()} local gate runtime degraded (${describeBrowserGateLocalRuntimeReason(current.reason)})`;
}
if (current.mode === 'unavailable' || current.health === 'unavailable') {
return current.reason
? `Local gate runtime unavailable (${describeBrowserGateLocalRuntimeReason(current.reason)})`
: 'Local gate runtime unavailable';
}
return 'Local gate runtime not checked yet';
}
function failureReasonKey(gateId: string, action: BrowserGateCryptoAction): string {
return `${normalizeGateId(gateId)}::${action}`;
}
function browserGateSelfEchoKey(gateId: string, ciphertext: string): string {
return `${normalizeGateId(gateId)}::${String(ciphertext || '').trim()}`;
}
function pruneBrowserGateSelfEchoCache(now: number = Date.now()): void {
for (const [key, entry] of browserGateSelfEchoCache.entries()) {
if (now - Number(entry.cachedAt || 0) > BROWSER_GATE_SELF_ECHO_TTL_MS) {
browserGateSelfEchoCache.delete(key);
}
}
while (browserGateSelfEchoCache.size > BROWSER_GATE_SELF_ECHO_MAX) {
const oldestKey = browserGateSelfEchoCache.keys().next().value;
if (!oldestKey) break;
browserGateSelfEchoCache.delete(oldestKey);
}
}
function rememberBrowserGateSelfEcho(
gateId: string,
ciphertext: string,
plaintext: string,
replyTo: string,
epoch: number,
): void {
const normalizedGate = normalizeGateId(gateId);
const normalizedCiphertext = String(ciphertext || '').trim();
if (!normalizedGate || !normalizedCiphertext) return;
pruneBrowserGateSelfEchoCache();
const key = browserGateSelfEchoKey(normalizedGate, normalizedCiphertext);
if (browserGateSelfEchoCache.has(key)) {
browserGateSelfEchoCache.delete(key);
}
browserGateSelfEchoCache.set(key, {
plaintext: String(plaintext || ''),
replyTo: String(replyTo || '').trim(),
epoch: Number(epoch || 0),
cachedAt: Date.now(),
});
pruneBrowserGateSelfEchoCache();
}
function peekBrowserGateSelfEcho(gateId: string, ciphertext: string): BrowserGateSelfEchoEntry | null {
const normalizedGate = normalizeGateId(gateId);
const normalizedCiphertext = String(ciphertext || '').trim();
if (!normalizedGate || !normalizedCiphertext) return null;
pruneBrowserGateSelfEchoCache();
const key = browserGateSelfEchoKey(normalizedGate, normalizedCiphertext);
const cached = browserGateSelfEchoCache.get(key);
if (!cached) return null;
browserGateSelfEchoCache.delete(key);
browserGateSelfEchoCache.set(key, cached);
return cached;
}
function clearBrowserGateSelfEcho(gateId?: string): void {
const normalizedGate = normalizeGateId(gateId || '');
if (!normalizedGate) {
browserGateSelfEchoCache.clear();
return;
}
for (const key of Array.from(browserGateSelfEchoCache.keys())) {
if (key.startsWith(`${normalizedGate}::`)) {
browserGateSelfEchoCache.delete(key);
}
}
}
function rememberBrowserGateCryptoFailure(
gateId: string,
action: BrowserGateCryptoAction,
reason: string,
): void {
const normalized = normalizeGateId(gateId);
if (!normalized) return;
browserGateCryptoFailureReasons.set(
failureReasonKey(normalized, action),
normalizeBrowserGateRuntimeReason(reason),
);
}
function clearBrowserGateCryptoFailure(gateId: string, action: BrowserGateCryptoAction): void {
const normalized = normalizeGateId(gateId);
if (!normalized) return;
browserGateCryptoFailureReasons.delete(failureReasonKey(normalized, action));
}
function rememberBrowserGateCryptoFailureForAllActions(gateId: string, reason: string): void {
(['compose', 'post', 'decrypt'] as BrowserGateCryptoAction[]).forEach((action) =>
rememberBrowserGateCryptoFailure(gateId, action, reason),
);
}
function clearBrowserGateCryptoFailureForAllActions(gateId: string): void {
(['compose', 'post', 'decrypt'] as BrowserGateCryptoAction[]).forEach((action) =>
clearBrowserGateCryptoFailure(gateId, action),
);
}
function markBrowserGateStateFresh(gateId: string): void {
const normalized = normalizeGateId(gateId);
if (!normalized) return;
browserGateStateSyncFreshUntil.set(normalized, Date.now() + BROWSER_GATE_STATE_SYNC_TTL_MS);
}
function clearBrowserGateStateFresh(gateId?: string): void {
const normalized = normalizeGateId(gateId || '');
if (!normalized) {
browserGateStateSyncFreshUntil.clear();
return;
}
browserGateStateSyncFreshUntil.delete(normalized);
}
function isBrowserGateStateFresh(gateId: string): boolean {
const normalized = normalizeGateId(gateId);
if (!normalized) return false;
return Number(browserGateStateSyncFreshUntil.get(normalized) || 0) > Date.now();
}
export function getBrowserGateCryptoFailureReason(
gateId: string,
action: BrowserGateCryptoAction,
): string {
return browserGateCryptoFailureReasons.get(failureReasonKey(gateId, action)) || '';
}
function ensureWorker(): Worker {
if (worker) return worker;
worker = new Worker(new URL('./meshGate.worker.ts', import.meta.url), { type: 'module' });
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const msg = event.data;
const handler = pending.get(msg.id);
if (!handler) return;
pending.delete(msg.id);
if (msg.ok) {
handler.resolve(msg.result);
} else {
handler.reject(new Error(msg.error || 'worker_error'));
}
};
return worker;
}
async function loadInlineRuntime() {
if (!browserGateInlineRuntimePromise) {
browserGateInlineRuntimePromise = import('./meshGateLocalRuntime');
}
return browserGateInlineRuntimePromise;
}
function callWorker<T>(payload: WorkerRequestPayload): Promise<T> {
const id = `gatew_${Date.now()}_${reqCounter++}`;
return new Promise((resolve, reject) => {
pending.set(id, { resolve: (value: unknown) => resolve(value as T), reject });
try {
ensureWorker().postMessage({ ...payload, id } as WorkerRequest);
} catch (error) {
pending.delete(id);
reject(error as Error);
}
});
}
async function callInlineRuntime<T>(payload: WorkerRequestPayload): Promise<T> {
const runtime = await loadInlineRuntime();
switch (payload.action) {
case 'supported':
return (await runtime.probeInlineGateCryptoSupport()) as T;
case 'adopt':
return (await runtime.adoptInlineGateState(payload.snapshot)) as T;
case 'compose':
return (await runtime.composeInlineGateMessage(
payload.gateId,
payload.plaintext,
payload.replyTo || '',
)) as T;
case 'decryptBatch':
return (await runtime.decryptInlineGateMessages(payload.messages)) as T;
case 'forget':
await runtime.forgetInlineGateState(payload.gateId);
return true as T;
default:
throw new Error('unsupported_gate_runtime_action');
}
}
async function callGateRuntime<T>(payload: WorkerRequestPayload): Promise<T> {
if (browserGateRuntimeMode === 'inline') {
return callInlineRuntime<T>(payload);
}
return callWorker<T>(payload);
}
async function ensureInlineBrowserGateCrypto(): Promise<boolean> {
try {
const support = await callInlineRuntime<InlineGateCryptoSupport>({ action: 'supported' });
if (support.supported) {
browserGateRuntimeMode = 'inline';
browserGateCryptoSupportReason = normalizeBrowserGateRuntimeReason(
browserGateCryptoSupportReason || 'browser_gate_worker_unavailable',
);
markBrowserGateLocalRuntimeActive('inline');
return true;
}
browserGateCryptoSupportReason = normalizeBrowserGateRuntimeReason(
support.reason || 'browser_gate_worker_unavailable',
);
} catch (error) {
browserGateCryptoSupportReason = normalizeBrowserGateRuntimeReason(
error instanceof Error ? error.message : 'browser_gate_worker_unavailable',
);
markBrowserGateLocalRuntimeUnavailable(browserGateCryptoSupportReason);
return false;
}
markBrowserGateLocalRuntimeUnavailable(browserGateCryptoSupportReason);
return false;
}
async function ensureBrowserGateCrypto(): Promise<boolean> {
if (typeof window === 'undefined') {
browserGateCryptoSupportReason = 'browser_runtime_unavailable';
markBrowserGateLocalRuntimeUnavailable(browserGateCryptoSupportReason);
return false;
}
if (!browserGateCryptoSupport) {
browserGateCryptoSupport = (async () => {
if (typeof Worker !== 'undefined') {
try {
await callWorker<boolean>({ action: 'supported' });
browserGateRuntimeMode = 'worker';
browserGateCryptoSupportReason = '';
markBrowserGateLocalRuntimeActive('worker');
return true;
} catch (error) {
browserGateCryptoSupportReason = normalizeBrowserGateRuntimeReason(
error instanceof Error ? error.message : 'browser_gate_worker_unavailable',
);
}
} else {
browserGateCryptoSupportReason = 'browser_gate_worker_unavailable';
}
return ensureInlineBrowserGateCrypto();
})();
}
return browserGateCryptoSupport;
}
async function exportGateStateSnapshot(gateId: string): Promise<WorkerGateStateSnapshot> {
return controlPlaneJson<WorkerGateStateSnapshot>('/api/wormhole/gate/state/export', {
requireAdminSession: false,
capabilityIntent: 'wormhole_gate_key',
sessionProfileHint: 'gate_operator',
enforceProfileHint: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate_id: gateId,
}),
});
}
async function adoptGateStateSnapshot(gateId: string): Promise<void> {
const snapshot = await exportGateStateSnapshot(gateId);
await callGateRuntime<WorkerGateStateSnapshot>({
action: 'adopt',
snapshot,
});
}
export async function syncBrowserGateState(
gateId: string,
options: { force?: boolean } = {},
): Promise<boolean> {
const normalizedGate = normalizeGateId(gateId);
if (!normalizedGate) return false;
if (!(await ensureBrowserGateCrypto())) {
rememberBrowserGateCryptoFailureForAllActions(
normalizedGate,
browserGateCryptoSupportReason || 'browser_gate_worker_unavailable',
);
return false;
}
if (!options.force && isBrowserGateStateFresh(normalizedGate)) {
return true;
}
try {
await adoptGateStateSnapshot(normalizedGate);
markBrowserGateStateFresh(normalizedGate);
clearBrowserGateCryptoFailureForAllActions(normalizedGate);
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
return true;
} catch (error) {
const detail = normalizeBrowserGateRuntimeReason(
(error instanceof Error ? error.message : String(error || '')).trim() ||
`browser_gate_state_resync_required:${normalizedGate}`,
);
rememberBrowserGateCryptoFailureForAllActions(normalizedGate, detail);
markBrowserGateLocalRuntimeDegraded(detail);
return false;
}
}
async function signEncryptedGateMessage(
gateId: string,
epoch: number,
ciphertext: string,
nonce: string,
recoveryPlaintext: string,
replyTo: string = '',
): Promise<SignedGateEnvelope> {
return controlPlaneJson<SignedGateEnvelope>('/api/wormhole/gate/message/sign-encrypted', {
requireAdminSession: false,
capabilityIntent: 'wormhole_gate_content',
sessionProfileHint: 'gate_operator',
enforceProfileHint: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate_id: gateId,
epoch,
ciphertext,
nonce,
format: 'mls1',
reply_to: replyTo,
compat_reply_to: Boolean(replyTo),
recovery_plaintext: recoveryPlaintext,
}),
});
}
type GatePostResult = { ok: boolean; detail?: string; event_id?: string };
function isGateEnvelopeRecoveryFailure(detail: string): boolean {
return detail === 'gate_envelope_required' || detail === 'gate_envelope_encrypt_failed';
}
async function postBackendSealedGateMessage(
gateId: string,
plaintext: string,
replyTo: string = '',
): Promise<GatePostResult> {
return controlPlaneJson<GatePostResult>('/api/wormhole/gate/message/post', {
requireAdminSession: false,
capabilityIntent: 'wormhole_gate_content',
sessionProfileHint: 'gate_operator',
enforceProfileHint: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate_id: normalizeGateId(gateId),
plaintext,
reply_to: replyTo,
compat_plaintext: true,
}),
});
}
async function postEncryptedGateMessage(envelope: SignedGateEnvelope): Promise<GatePostResult> {
return controlPlaneJson<{ ok: boolean; detail?: string }>('/api/wormhole/gate/message/post-encrypted', {
requireAdminSession: false,
capabilityIntent: 'wormhole_gate_content',
sessionProfileHint: 'gate_operator',
enforceProfileHint: true,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gate_id: envelope.gate_id,
sender_id: envelope.sender_id,
public_key: envelope.public_key,
public_key_algo: envelope.public_key_algo,
signature: envelope.signature,
sequence: envelope.sequence,
protocol_version: envelope.protocol_version,
epoch: envelope.epoch,
ciphertext: envelope.ciphertext,
nonce: envelope.nonce,
sender_ref: envelope.sender_ref,
format: envelope.format || 'mls1',
gate_envelope: envelope.gate_envelope || '',
envelope_hash: envelope.envelope_hash || '',
reply_to: '',
compat_reply_to: false,
}),
});
}
function isResyncRequired(detail: string, gateId?: string): boolean {
const normalizedGate = normalizeGateId(gateId || '');
return detail === `browser_gate_state_resync_required:${normalizedGate}` || detail.startsWith('browser_gate_state_resync_required:');
}
export async function composeBrowserGateMessage(
gateId: string,
plaintext: string,
replyTo: string = '',
): Promise<SignedGateEnvelope | null> {
if (!(await ensureBrowserGateCrypto())) {
rememberBrowserGateCryptoFailure(
gateId,
'compose',
browserGateCryptoSupportReason || 'browser_gate_worker_unavailable',
);
return null;
}
const normalizedGate = normalizeGateId(gateId);
for (let attempt = 0; attempt < 2; attempt += 1) {
let local: BrowserGateComposeResult;
try {
local = await callGateRuntime<BrowserGateComposeResult | LocalGateComposeResult>({
action: 'compose',
gateId: normalizedGate,
plaintext,
replyTo,
}) as BrowserGateComposeResult;
} catch (error) {
const detail = normalizeBrowserGateRuntimeReason(error instanceof Error ? error.message : String(error || ''));
if (isResyncRequired(detail, normalizedGate) && attempt === 0) {
if (await syncBrowserGateState(normalizedGate, { force: true })) {
continue;
}
return null;
}
rememberBrowserGateCryptoFailure(normalizedGate, 'compose', detail || 'browser_local_gate_crypto_unavailable');
markBrowserGateLocalRuntimeDegraded(detail);
return null;
}
const signed = await signEncryptedGateMessage(
normalizedGate,
Number(local.epoch || 0),
String(local.ciphertext || ''),
String(local.nonce || ''),
plaintext,
replyTo,
);
if (!signed.ok && String(signed.detail || '') === 'gate_state_stale' && attempt === 0) {
if (await syncBrowserGateState(normalizedGate, { force: true })) {
continue;
}
return null;
}
if (signed.ok) {
rememberBrowserGateSelfEcho(
normalizedGate,
String(signed.ciphertext || local.ciphertext || ''),
plaintext,
replyTo,
Number(signed.epoch || local.epoch || 0),
);
}
markBrowserGateStateFresh(normalizedGate);
clearBrowserGateCryptoFailure(normalizedGate, 'compose');
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
return signed;
}
rememberBrowserGateCryptoFailure(normalizedGate, 'compose', 'browser_local_gate_crypto_unavailable');
markBrowserGateLocalRuntimeDegraded('browser_local_gate_crypto_unavailable');
return null;
}
export async function postBrowserGateMessage(
gateId: string,
plaintext: string,
replyTo: string = '',
): Promise<GatePostResult | null> {
const signed = await composeBrowserGateMessage(gateId, plaintext, replyTo);
if (!signed) {
rememberBrowserGateCryptoFailure(
gateId,
'post',
getBrowserGateCryptoFailureReason(gateId, 'compose') || 'browser_local_gate_crypto_unavailable',
);
return null;
}
if (!signed.ok) {
if (isGateEnvelopeRecoveryFailure(String(signed.detail || ''))) {
const fallback = await postBackendSealedGateMessage(gateId, plaintext, replyTo);
if (fallback?.ok) {
clearBrowserGateCryptoFailure(gateId, 'post');
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
}
return fallback;
}
return { ok: false, detail: signed.detail || 'gate_sign_failed' };
}
if (!String(signed.gate_envelope || '').trim() || !String(signed.envelope_hash || '').trim()) {
const fallback = await postBackendSealedGateMessage(gateId, plaintext, replyTo);
if (fallback?.ok) {
clearBrowserGateCryptoFailure(gateId, 'post');
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
return fallback;
}
rememberBrowserGateCryptoFailure(gateId, 'post', fallback?.detail || 'gate_envelope_required');
markBrowserGateLocalRuntimeDegraded(fallback?.detail || 'gate_envelope_required');
return fallback || { ok: false, detail: 'gate_envelope_required' };
}
const result = await postEncryptedGateMessage(signed);
if (result?.ok) {
clearBrowserGateCryptoFailure(gateId, 'post');
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
} else if (result?.detail && String(result.detail || '') !== 'gate_sign_failed') {
markBrowserGateLocalRuntimeDegraded(String(result.detail || 'browser_local_gate_crypto_unavailable'));
}
return result;
}
export async function decryptBrowserGateMessages(
messages: Array<{ gate_id: string; epoch?: number; ciphertext: string }>,
): Promise<{ ok: boolean; detail?: string; results: BrowserGateDecryptResult[] } | null> {
const gateIds = Array.from(new Set(messages.map((message) => normalizeGateId(message.gate_id)).filter(Boolean)));
if (!(await ensureBrowserGateCrypto())) {
gateIds.forEach((gateId) =>
rememberBrowserGateCryptoFailure(
gateId,
'decrypt',
browserGateCryptoSupportReason || 'browser_gate_worker_unavailable',
),
);
return null;
}
let batchError = '';
let batch = await callGateRuntime<BrowserGateDecryptResult[] | LocalGateDecryptResult[]>({
action: 'decryptBatch',
messages,
}).catch((error) => {
batchError = normalizeBrowserGateRuntimeReason(
error instanceof Error ? error.message : 'browser_local_gate_crypto_unavailable',
);
return null;
});
if (!batch) {
gateIds.forEach((gateId) =>
rememberBrowserGateCryptoFailure(gateId, 'decrypt', batchError || 'browser_local_gate_crypto_unavailable'),
);
markBrowserGateLocalRuntimeDegraded(batchError || 'browser_local_gate_crypto_unavailable');
return null;
}
const resyncGateIds = Array.from(
new Set(
batch
.filter((result) => !result?.ok && isResyncRequired(String(result?.detail || ''), String(result?.gate_id || '')))
.map((result) => normalizeGateId(String(result.gate_id || '')))
.filter(Boolean),
),
);
if (resyncGateIds.length > 0) {
const synced = await Promise.all(
resyncGateIds.map((gateId) => syncBrowserGateState(gateId, { force: true })),
);
if (synced.some((value) => !value)) {
return null;
}
batch = await callGateRuntime<BrowserGateDecryptResult[] | LocalGateDecryptResult[]>({
action: 'decryptBatch',
messages,
}).catch((error) => {
batchError = normalizeBrowserGateRuntimeReason(
error instanceof Error ? error.message : 'browser_local_gate_crypto_unavailable',
);
return null;
});
if (!batch) {
gateIds.forEach((gateId) =>
rememberBrowserGateCryptoFailure(gateId, 'decrypt', batchError || 'browser_local_gate_crypto_unavailable'),
);
markBrowserGateLocalRuntimeDegraded(batchError || 'browser_local_gate_crypto_unavailable');
return null;
}
}
const normalizedBatch = (batch as BrowserGateDecryptResult[]).map((result, index) => {
if (result?.ok || String(result?.detail || '').trim() !== 'gate_mls_decrypt_failed') {
return result;
}
const source = messages[index];
const cached = peekBrowserGateSelfEcho(
String(source?.gate_id || result?.gate_id || ''),
String(source?.ciphertext || ''),
);
if (!cached) {
return result;
}
return {
ok: true,
gate_id: normalizeGateId(String(source?.gate_id || result?.gate_id || '')),
epoch: Number(cached.epoch || source?.epoch || result?.epoch || 0),
plaintext: cached.plaintext,
reply_to: cached.replyTo,
identity_scope: 'browser_self_echo',
} satisfies BrowserGateDecryptResult;
});
const degradedDetail = normalizedBatch.find(
(result) => !result?.ok && String(result?.detail || '').trim(),
)?.detail;
if (degradedDetail) {
markBrowserGateLocalRuntimeDegraded(String(degradedDetail));
} else {
markBrowserGateLocalRuntimeActive(browserGateRuntimeMode);
}
gateIds.forEach((gateId) => {
markBrowserGateStateFresh(gateId);
clearBrowserGateCryptoFailure(gateId, 'decrypt');
});
return {
ok: true,
detail: degradedDetail,
results: normalizedBatch,
};
}
export async function forgetBrowserGateState(gateId?: string): Promise<void> {
clearBrowserGateStateFresh(gateId);
clearBrowserGateSelfEcho(gateId);
if (!(await ensureBrowserGateCrypto())) return;
await callGateRuntime<boolean>({
action: 'forget',
gateId,
}).catch(() => {});
}
+205
View File
@@ -0,0 +1,205 @@
export type WorkerGateStateMember = {
persona_id: string;
node_id: string;
identity_scope: string;
group_handle: number;
};
export type WorkerGateStateSnapshot = {
gate_id: string;
epoch: number;
rust_state_blob_b64: string;
members: WorkerGateStateMember[];
active_identity_scope: string;
active_persona_id: string;
active_node_id: string;
};
export const WORKER_GATE_DB = 'sb_mesh_gate_worker';
const WORKER_GATE_DB_VERSION = 1;
const WORKER_GATE_STATE_STORE = 'gate_state';
const WORKER_GATE_META_STORE = 'meta';
const WORKER_GATE_WRAP_KEY_ID = 'gate_wrap_key';
function openWorkerGateDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const open = indexedDB.open(WORKER_GATE_DB, WORKER_GATE_DB_VERSION);
open.onupgradeneeded = () => {
const db = open.result;
if (!db.objectStoreNames.contains(WORKER_GATE_STATE_STORE)) {
db.createObjectStore(WORKER_GATE_STATE_STORE);
}
if (!db.objectStoreNames.contains(WORKER_GATE_META_STORE)) {
db.createObjectStore(WORKER_GATE_META_STORE);
}
};
open.onsuccess = () => resolve(open.result);
open.onerror = () => reject(open.error);
});
}
function readValue<T>(db: IDBDatabase, storeName: string, key: string): Promise<T | null> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const req = store.get(key);
req.onsuccess = () => resolve((req.result as T | undefined) ?? null);
req.onerror = () => reject(req.error);
tx.onabort = () => reject(tx.error);
});
}
function writeValue(db: IDBDatabase, storeName: string, key: string, value: unknown): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).put(value, key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
function deleteValue(db: IDBDatabase, storeName: string, key: string): Promise<void> {
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
tx.objectStore(storeName).delete(key);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
async function getStoredWrapKey(db: IDBDatabase): Promise<CryptoKey | null> {
return readValue<CryptoKey>(db, WORKER_GATE_META_STORE, WORKER_GATE_WRAP_KEY_ID);
}
async function getOrCreateWrapKey(db: IDBDatabase): Promise<CryptoKey> {
const existing = await getStoredWrapKey(db);
if (existing) return existing;
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, [
'encrypt',
'decrypt',
]);
await writeValue(db, WORKER_GATE_META_STORE, WORKER_GATE_WRAP_KEY_ID, key);
return key;
}
async function encryptSerializedState(db: IDBDatabase, serialized: string): Promise<string> {
const key = await getOrCreateWrapKey(db);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(serialized);
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
}
async function decryptSerializedState(db: IDBDatabase, encrypted: string): Promise<string> {
const key = await getStoredWrapKey(db);
if (!key) {
throw new Error('worker_gate_wrap_key_missing');
}
const combined = Uint8Array.from(atob(encrypted), (char) => char.charCodeAt(0));
const iv = combined.slice(0, 12);
const ciphertext = combined.slice(12);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
return new TextDecoder().decode(decrypted);
}
function normalizeSnapshot(value: unknown): WorkerGateStateSnapshot | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const snapshot = value as WorkerGateStateSnapshot;
if (!snapshot.gate_id || !snapshot.rust_state_blob_b64) return null;
return snapshot;
}
export async function readWorkerGateState(gateId: string): Promise<WorkerGateStateSnapshot | null> {
const db = await openWorkerGateDb();
try {
const persisted = await readValue<unknown>(db, WORKER_GATE_STATE_STORE, gateId);
if (persisted == null) return null;
if (typeof persisted === 'string') {
try {
const decrypted = await decryptSerializedState(db, persisted);
return normalizeSnapshot(JSON.parse(decrypted));
} catch {
await deleteValue(db, WORKER_GATE_STATE_STORE, gateId);
return null;
}
}
const legacy = normalizeSnapshot(persisted);
if (legacy) {
try {
const encrypted = await encryptSerializedState(db, JSON.stringify(legacy));
await writeValue(db, WORKER_GATE_STATE_STORE, gateId, encrypted);
} catch {
await deleteValue(db, WORKER_GATE_STATE_STORE, gateId);
}
}
return legacy;
} finally {
db.close();
}
}
export async function writeWorkerGateState(snapshot: WorkerGateStateSnapshot): Promise<void> {
const db = await openWorkerGateDb();
try {
const encrypted = await encryptSerializedState(db, JSON.stringify(snapshot));
await writeValue(db, WORKER_GATE_STATE_STORE, snapshot.gate_id, encrypted);
} finally {
db.close();
}
}
export async function deleteWorkerGateState(gateId: string): Promise<void> {
const db = await openWorkerGateDb();
try {
await deleteValue(db, WORKER_GATE_STATE_STORE, gateId);
} finally {
db.close();
}
}
export async function clearWorkerGateStates(): Promise<void> {
const db = await openWorkerGateDb();
try {
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(WORKER_GATE_STATE_STORE, 'readwrite');
tx.objectStore(WORKER_GATE_STATE_STORE).clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
} finally {
db.close();
}
}
export async function probeWorkerGateVaultAvailability(): Promise<{ ok: boolean; reason: string }> {
if (typeof indexedDB === 'undefined') {
return { ok: false, reason: 'browser_gate_indexeddb_unavailable' };
}
try {
const db = await openWorkerGateDb();
db.close();
return { ok: true, reason: '' };
} catch {
return { ok: false, reason: 'browser_gate_storage_unavailable' };
}
}
export async function deleteWorkerGateDatabase(): Promise<void> {
if (typeof indexedDB === 'undefined') return;
await new Promise<void>((resolve) => {
try {
const req = indexedDB.deleteDatabase(WORKER_GATE_DB);
req.onsuccess = () => resolve();
req.onerror = () => resolve();
req.onblocked = () => resolve();
} catch {
resolve();
}
});
}
+260 -19
View File
@@ -9,6 +9,7 @@
*/
import { buildSignaturePayload, PROTOCOL_VERSION, type JsonValue } from '@/mesh/meshProtocol';
import type { ContactTrustSummary } from '@/mesh/contactTrustTypes';
import { deleteKey, getKey, setKey } from '@/mesh/meshKeyStore';
import { purgeMailboxClaimKey } from '@/mesh/meshMailbox';
import { controlPlaneJson } from '@/lib/controlPlane';
@@ -155,7 +156,7 @@ async function deleteDatabaseIfPresent(name: string): Promise<void> {
export interface NodeIdentity {
publicKey: string; // Base64-encoded public key
privateKey: string; // Base64-encoded private key (never sent to server)
nodeId: string; // !sb_ + first 16 hex chars of public key hash
nodeId: string; // !sb_ + first 32 hex chars of public key hash
}
export interface NodeDescriptor {
@@ -164,18 +165,34 @@ export interface NodeDescriptor {
publicKeyAlgo: string;
}
function isLegacyNodeId(nodeId: string): boolean {
function isNodeIdWithLength(nodeId: string, length: number): boolean {
const value = String(nodeId || '').trim();
return /^!sb_[0-9a-f]{8}$/i.test(value);
return new RegExp(`^${NODE_ID_PREFIX}[0-9a-f]{${length}}$`, 'i').test(value);
}
async function migrateStoredNodeIdIfLegacy(
function isLegacyNodeId(nodeId: string): boolean {
return isNodeIdWithLength(nodeId, NODE_ID_LEGACY_HEX_LEN);
}
function isCompatNodeId(nodeId: string): boolean {
return isNodeIdWithLength(nodeId, NODE_ID_COMPAT_HEX_LEN);
}
function isCurrentNodeId(nodeId: string): boolean {
return isNodeIdWithLength(nodeId, NODE_ID_HEX_LEN);
}
function isMigratableStoredNodeId(nodeId: string): boolean {
return isLegacyNodeId(nodeId) || isCompatNodeId(nodeId);
}
async function migrateStoredNodeIdIfNeeded(
publicKeyBase64: string,
nodeId: string,
persist: (nextNodeId: string) => void,
): Promise<string> {
const current = await deriveNodeIdFromPublicKey(publicKeyBase64);
if (!isLegacyNodeId(nodeId) || nodeId === current) return current;
if (!isMigratableStoredNodeId(nodeId) || nodeId === current) return current;
persist(current);
return current;
}
@@ -397,10 +414,25 @@ function utf8ToBuf(value: string): Uint8Array<ArrayBuffer> {
return new TextEncoder().encode(value);
}
/** Generate a Node ID from the public key: !sb_ + first 16 hex chars of SHA-256. */
async function deriveNodeId(publicKeyRaw: ArrayBuffer): Promise<string> {
async function deriveNodeIdForLength(publicKeyRaw: ArrayBuffer, length: number): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', publicKeyRaw);
return NODE_ID_PREFIX + bufToHex(hash).slice(0, NODE_ID_HEX_LEN);
return NODE_ID_PREFIX + bufToHex(hash).slice(0, length);
}
/** Generate a Node ID from the public key: !sb_ + first 32 hex chars of SHA-256. */
async function deriveNodeId(publicKeyRaw: ArrayBuffer): Promise<string> {
return deriveNodeIdForLength(publicKeyRaw, NODE_ID_HEX_LEN);
}
async function deriveNodeIdCandidates(publicKeyRaw: ArrayBuffer): Promise<string[]> {
const candidates: string[] = [];
for (const length of [NODE_ID_HEX_LEN, NODE_ID_COMPAT_HEX_LEN]) {
const candidate = await deriveNodeIdForLength(publicKeyRaw, length);
if (!candidates.includes(candidate)) {
candidates.push(candidate);
}
}
return candidates;
}
export async function deriveNodeIdFromPublicKey(publicKeyBase64: string): Promise<string> {
@@ -414,8 +446,8 @@ export async function verifyNodeIdBindingFromPublicKey(
): Promise<boolean> {
try {
const raw = base64ToBuf(publicKeyBase64);
const current = await deriveNodeId(raw);
return current === String(nodeId || '');
const candidates = await deriveNodeIdCandidates(raw);
return candidates.includes(String(nodeId || '').trim());
} catch {
return false;
}
@@ -426,9 +458,9 @@ export async function migrateLegacyNodeIds(): Promise<void> {
const publicKey = storageGet(KEY_PUBKEY);
const nodeId = storageGet(KEY_NODE_ID);
if (publicKey && nodeId && isLegacyNodeId(nodeId)) {
if (publicKey && nodeId && isMigratableStoredNodeId(nodeId) && !isCurrentNodeId(nodeId)) {
try {
const current = await migrateStoredNodeIdIfLegacy(publicKey, nodeId, (nextNodeId) => {
const current = await migrateStoredNodeIdIfNeeded(publicKey, nodeId, (nextNodeId) => {
storageSet(KEY_NODE_ID, nextNodeId);
});
if (current !== nodeId) {
@@ -442,11 +474,20 @@ export async function migrateLegacyNodeIds(): Promise<void> {
try {
const wormholePub = sessionStorage.getItem(KEY_WORMHOLE_PUBKEY);
const wormholeNode = sessionStorage.getItem(KEY_WORMHOLE_NODE_ID);
if (wormholePub && wormholeNode && isLegacyNodeId(wormholeNode)) {
if (
wormholePub &&
wormholeNode &&
isMigratableStoredNodeId(wormholeNode) &&
!isCurrentNodeId(wormholeNode)
) {
try {
const current = await migrateStoredNodeIdIfLegacy(wormholePub, wormholeNode, (nextNodeId) => {
sessionStorage.setItem(KEY_WORMHOLE_NODE_ID, nextNodeId);
});
const current = await migrateStoredNodeIdIfNeeded(
wormholePub,
wormholeNode,
(nextNodeId) => {
sessionStorage.setItem(KEY_WORMHOLE_NODE_ID, nextNodeId);
},
);
if (current !== wormholeNode) {
console.warn(`[mesh] migrated legacy Wormhole descriptor ${wormholeNode} -> ${current}`);
}
@@ -1284,27 +1325,80 @@ export interface Contact {
verified?: boolean;
verify_mismatch?: boolean;
verified_at?: number;
trust_level?: string;
invitePinnedTrustFingerprint?: string;
invitePinnedNodeId?: string;
invitePinnedPublicKey?: string;
invitePinnedPublicKeyAlgo?: string;
invitePinnedDhPubKey?: string;
invitePinnedDhAlgo?: string;
invitePinnedPrekeyLookupHandle?: string;
invitePinnedRootFingerprint?: string;
invitePinnedRootManifestFingerprint?: string;
invitePinnedRootWitnessPolicyFingerprint?: string;
invitePinnedRootWitnessThreshold?: number;
invitePinnedRootWitnessCount?: number;
invitePinnedRootWitnessDomainCount?: number;
invitePinnedRootManifestGeneration?: number;
invitePinnedRootRotationProven?: boolean;
invitePinnedRootNodeId?: string;
invitePinnedRootPublicKey?: string;
invitePinnedRootPublicKeyAlgo?: string;
invitePinnedIssuedAt?: number;
invitePinnedExpiresAt?: number;
invitePinnedAt?: number;
remotePrekeyFingerprint?: string;
remotePrekeyObservedFingerprint?: string;
remotePrekeyRootFingerprint?: string;
remotePrekeyRootManifestFingerprint?: string;
remotePrekeyRootWitnessPolicyFingerprint?: string;
remotePrekeyRootWitnessThreshold?: number;
remotePrekeyRootWitnessCount?: number;
remotePrekeyRootWitnessDomainCount?: number;
remotePrekeyRootManifestGeneration?: number;
remotePrekeyRootRotationProven?: boolean;
remotePrekeyObservedRootFingerprint?: string;
remotePrekeyObservedRootManifestFingerprint?: string;
remotePrekeyObservedRootWitnessPolicyFingerprint?: string;
remotePrekeyObservedRootWitnessThreshold?: number;
remotePrekeyObservedRootWitnessCount?: number;
remotePrekeyObservedRootWitnessDomainCount?: number;
remotePrekeyObservedRootManifestGeneration?: number;
remotePrekeyObservedRootRotationProven?: boolean;
remotePrekeyRootNodeId?: string;
remotePrekeyRootPublicKey?: string;
remotePrekeyRootPublicKeyAlgo?: string;
remotePrekeyRootPinnedAt?: number;
remotePrekeyRootLastSeenAt?: number;
remotePrekeyRootMismatch?: boolean;
remotePrekeyPinnedAt?: number;
remotePrekeyLastSeenAt?: number;
remotePrekeySequence?: number;
remotePrekeySignedAt?: number;
remotePrekeyMismatch?: boolean;
remotePrekeyTransparencyHead?: string;
remotePrekeyTransparencySize?: number;
remotePrekeyTransparencySeenAt?: number;
remotePrekeyTransparencyConflict?: boolean;
remotePrekeyLookupMode?: string;
witness_count?: number;
witness_checked_at?: number;
vouch_count?: number;
vouch_checked_at?: number;
trustSummary?: ContactTrustSummary;
}
let contactCache: Record<string, Contact> = {};
let contactsHydration: Promise<Record<string, Contact>> | null = null;
let contactsPersistGeneration = 0;
let contactsPersistQueue: Promise<void> = Promise.resolve();
function shouldUseWormholeContacts(): boolean {
return isSecureModeCached();
}
function sanitizeContact(contact: Partial<Contact> | undefined): Contact {
const trustSummary = contact?.trustSummary;
return {
alias: String(contact?.alias || ''),
blocked: Boolean(contact?.blocked),
@@ -1312,7 +1406,7 @@ function sanitizeContact(contact: Partial<Contact> | undefined): Contact {
dhAlgo: String(contact?.dhAlgo || ''),
sharedAlias: String(contact?.sharedAlias || ''),
previousSharedAliases: Array.isArray(contact?.previousSharedAliases)
? contact?.previousSharedAliases.filter(Boolean).map(String).slice(-8)
? contact?.previousSharedAliases.filter(Boolean).map(String).slice(-2)
: [],
pendingSharedAlias: String(contact?.pendingSharedAlias || ''),
sharedAliasGraceUntil: Number(contact?.sharedAliasGraceUntil || 0),
@@ -1322,17 +1416,115 @@ function sanitizeContact(contact: Partial<Contact> | undefined): Contact {
verified: Boolean(contact?.verified),
verify_mismatch: Boolean(contact?.verify_mismatch),
verified_at: Number(contact?.verified_at || 0),
trust_level: String(contact?.trust_level || ''),
invitePinnedTrustFingerprint: String(contact?.invitePinnedTrustFingerprint || ''),
invitePinnedNodeId: String(contact?.invitePinnedNodeId || ''),
invitePinnedPublicKey: String(contact?.invitePinnedPublicKey || ''),
invitePinnedPublicKeyAlgo: String(contact?.invitePinnedPublicKeyAlgo || ''),
invitePinnedDhPubKey: String(contact?.invitePinnedDhPubKey || ''),
invitePinnedDhAlgo: String(contact?.invitePinnedDhAlgo || ''),
invitePinnedPrekeyLookupHandle: String(contact?.invitePinnedPrekeyLookupHandle || ''),
invitePinnedRootFingerprint: String(contact?.invitePinnedRootFingerprint || ''),
invitePinnedRootManifestFingerprint: String(contact?.invitePinnedRootManifestFingerprint || ''),
invitePinnedRootWitnessPolicyFingerprint: String(
contact?.invitePinnedRootWitnessPolicyFingerprint || '',
),
invitePinnedRootWitnessThreshold: Number(contact?.invitePinnedRootWitnessThreshold || 0),
invitePinnedRootWitnessCount: Number(contact?.invitePinnedRootWitnessCount || 0),
invitePinnedRootWitnessDomainCount: Number(contact?.invitePinnedRootWitnessDomainCount || 0),
invitePinnedRootManifestGeneration: Number(contact?.invitePinnedRootManifestGeneration || 0),
invitePinnedRootRotationProven: Boolean(contact?.invitePinnedRootRotationProven),
invitePinnedRootNodeId: String(contact?.invitePinnedRootNodeId || ''),
invitePinnedRootPublicKey: String(contact?.invitePinnedRootPublicKey || ''),
invitePinnedRootPublicKeyAlgo: String(contact?.invitePinnedRootPublicKeyAlgo || ''),
invitePinnedIssuedAt: Number(contact?.invitePinnedIssuedAt || 0),
invitePinnedExpiresAt: Number(contact?.invitePinnedExpiresAt || 0),
invitePinnedAt: Number(contact?.invitePinnedAt || 0),
remotePrekeyFingerprint: String(contact?.remotePrekeyFingerprint || ''),
remotePrekeyObservedFingerprint: String(contact?.remotePrekeyObservedFingerprint || ''),
remotePrekeyRootFingerprint: String(contact?.remotePrekeyRootFingerprint || ''),
remotePrekeyRootManifestFingerprint: String(contact?.remotePrekeyRootManifestFingerprint || ''),
remotePrekeyRootWitnessPolicyFingerprint: String(
contact?.remotePrekeyRootWitnessPolicyFingerprint || '',
),
remotePrekeyRootWitnessThreshold: Number(contact?.remotePrekeyRootWitnessThreshold || 0),
remotePrekeyRootWitnessCount: Number(contact?.remotePrekeyRootWitnessCount || 0),
remotePrekeyRootWitnessDomainCount: Number(contact?.remotePrekeyRootWitnessDomainCount || 0),
remotePrekeyRootManifestGeneration: Number(contact?.remotePrekeyRootManifestGeneration || 0),
remotePrekeyRootRotationProven: Boolean(contact?.remotePrekeyRootRotationProven),
remotePrekeyObservedRootFingerprint: String(contact?.remotePrekeyObservedRootFingerprint || ''),
remotePrekeyObservedRootManifestFingerprint: String(
contact?.remotePrekeyObservedRootManifestFingerprint || '',
),
remotePrekeyObservedRootWitnessPolicyFingerprint: String(
contact?.remotePrekeyObservedRootWitnessPolicyFingerprint || '',
),
remotePrekeyObservedRootWitnessThreshold: Number(
contact?.remotePrekeyObservedRootWitnessThreshold || 0,
),
remotePrekeyObservedRootWitnessCount: Number(contact?.remotePrekeyObservedRootWitnessCount || 0),
remotePrekeyObservedRootWitnessDomainCount: Number(
contact?.remotePrekeyObservedRootWitnessDomainCount || 0,
),
remotePrekeyObservedRootManifestGeneration: Number(
contact?.remotePrekeyObservedRootManifestGeneration || 0,
),
remotePrekeyObservedRootRotationProven: Boolean(contact?.remotePrekeyObservedRootRotationProven),
remotePrekeyRootNodeId: String(contact?.remotePrekeyRootNodeId || ''),
remotePrekeyRootPublicKey: String(contact?.remotePrekeyRootPublicKey || ''),
remotePrekeyRootPublicKeyAlgo: String(contact?.remotePrekeyRootPublicKeyAlgo || ''),
remotePrekeyRootPinnedAt: Number(contact?.remotePrekeyRootPinnedAt || 0),
remotePrekeyRootLastSeenAt: Number(contact?.remotePrekeyRootLastSeenAt || 0),
remotePrekeyRootMismatch: Boolean(contact?.remotePrekeyRootMismatch),
remotePrekeyPinnedAt: Number(contact?.remotePrekeyPinnedAt || 0),
remotePrekeyLastSeenAt: Number(contact?.remotePrekeyLastSeenAt || 0),
remotePrekeySequence: Number(contact?.remotePrekeySequence || 0),
remotePrekeySignedAt: Number(contact?.remotePrekeySignedAt || 0),
remotePrekeyMismatch: Boolean(contact?.remotePrekeyMismatch),
remotePrekeyTransparencyHead: String(contact?.remotePrekeyTransparencyHead || ''),
remotePrekeyTransparencySize: Number(contact?.remotePrekeyTransparencySize || 0),
remotePrekeyTransparencySeenAt: Number(contact?.remotePrekeyTransparencySeenAt || 0),
remotePrekeyTransparencyConflict: Boolean(contact?.remotePrekeyTransparencyConflict),
remotePrekeyLookupMode: String(contact?.remotePrekeyLookupMode || '').trim().toLowerCase(),
witness_count: Number(contact?.witness_count || 0),
witness_checked_at: Number(contact?.witness_checked_at || 0),
vouch_count: Number(contact?.vouch_count || 0),
vouch_checked_at: Number(contact?.vouch_checked_at || 0),
trustSummary: trustSummary
? {
state: String(trustSummary.state || '').trim(),
label: String(trustSummary.label || '').trim(),
severity: String(trustSummary.severity || 'warn').trim() as ContactTrustSummary['severity'],
detail: String(trustSummary.detail || '').trim(),
verifiedFirstContact: Boolean(trustSummary.verifiedFirstContact),
recommendedAction: String(
trustSummary.recommendedAction || 'show_sas',
).trim() as ContactTrustSummary['recommendedAction'],
legacyLookup: Boolean(trustSummary.legacyLookup),
inviteAttested: Boolean(trustSummary.inviteAttested),
rootAttested: Boolean(trustSummary.rootAttested),
rootWitnessed: Boolean(trustSummary.rootWitnessed),
rootDistributionState: String(
trustSummary.rootDistributionState || 'none',
).trim() as ContactTrustSummary['rootDistributionState'],
rootWitnessPolicyFingerprint: String(trustSummary.rootWitnessPolicyFingerprint || ''),
rootWitnessCount: Number(trustSummary.rootWitnessCount || 0),
rootWitnessThreshold: Number(trustSummary.rootWitnessThreshold || 0),
rootWitnessQuorumMet: Boolean(trustSummary.rootWitnessQuorumMet),
rootWitnessProvenanceState: String(
trustSummary.rootWitnessProvenanceState || 'none',
).trim() as ContactTrustSummary['rootWitnessProvenanceState'],
rootWitnessDomainCount: Number(trustSummary.rootWitnessDomainCount || 0),
rootWitnessIndependentQuorumMet: Boolean(
trustSummary.rootWitnessIndependentQuorumMet,
),
rootManifestGeneration: Number(trustSummary.rootManifestGeneration || 0),
rootRotationProven: Boolean(trustSummary.rootRotationProven),
rootMismatch: Boolean(trustSummary.rootMismatch),
registryMismatch: Boolean(trustSummary.registryMismatch),
transparencyConflict: Boolean(trustSummary.transparencyConflict),
}
: undefined,
};
}
@@ -1429,13 +1621,28 @@ async function persistStoredContacts(contacts: Record<string, Contact>): Promise
}
}
function schedulePersistStoredContacts(contacts: Record<string, Contact>): void {
const generation = ++contactsPersistGeneration;
const snapshot = normalizeContactMap(contacts);
contactsPersistQueue = contactsPersistQueue
.catch(() => {
/* preserve queue progression after prior persist errors */
})
.then(async () => {
if (generation !== contactsPersistGeneration) {
return;
}
await persistStoredContacts(snapshot);
});
}
function saveContacts(contacts: Record<string, Contact>): void {
const normalized = normalizeContactMap(contacts);
contactCache = normalized;
if (shouldUseWormholeContacts()) {
return;
}
void persistStoredContacts(normalized);
schedulePersistStoredContacts(normalized);
}
export function addContact(agentId: string, dhPubKey: string, alias?: string, dhAlgo?: string): void {
@@ -1455,13 +1662,45 @@ export function addContact(agentId: string, dhPubKey: string, alias?: string, dh
verified: contacts[agentId]?.verified,
verify_mismatch: contacts[agentId]?.verify_mismatch,
verified_at: contacts[agentId]?.verified_at,
trust_level: contacts[agentId]?.trust_level,
invitePinnedTrustFingerprint: contacts[agentId]?.invitePinnedTrustFingerprint,
invitePinnedNodeId: contacts[agentId]?.invitePinnedNodeId,
invitePinnedPublicKey: contacts[agentId]?.invitePinnedPublicKey,
invitePinnedPublicKeyAlgo: contacts[agentId]?.invitePinnedPublicKeyAlgo,
invitePinnedDhPubKey: contacts[agentId]?.invitePinnedDhPubKey,
invitePinnedDhAlgo: contacts[agentId]?.invitePinnedDhAlgo,
invitePinnedPrekeyLookupHandle: contacts[agentId]?.invitePinnedPrekeyLookupHandle,
invitePinnedRootFingerprint: contacts[agentId]?.invitePinnedRootFingerprint,
invitePinnedRootNodeId: contacts[agentId]?.invitePinnedRootNodeId,
invitePinnedRootPublicKey: contacts[agentId]?.invitePinnedRootPublicKey,
invitePinnedRootPublicKeyAlgo: contacts[agentId]?.invitePinnedRootPublicKeyAlgo,
invitePinnedIssuedAt: contacts[agentId]?.invitePinnedIssuedAt,
invitePinnedExpiresAt: contacts[agentId]?.invitePinnedExpiresAt,
invitePinnedAt: contacts[agentId]?.invitePinnedAt,
remotePrekeyFingerprint: contacts[agentId]?.remotePrekeyFingerprint,
remotePrekeyObservedFingerprint: contacts[agentId]?.remotePrekeyObservedFingerprint,
remotePrekeyRootFingerprint: contacts[agentId]?.remotePrekeyRootFingerprint,
remotePrekeyObservedRootFingerprint: contacts[agentId]?.remotePrekeyObservedRootFingerprint,
remotePrekeyRootNodeId: contacts[agentId]?.remotePrekeyRootNodeId,
remotePrekeyRootPublicKey: contacts[agentId]?.remotePrekeyRootPublicKey,
remotePrekeyRootPublicKeyAlgo: contacts[agentId]?.remotePrekeyRootPublicKeyAlgo,
remotePrekeyRootPinnedAt: contacts[agentId]?.remotePrekeyRootPinnedAt,
remotePrekeyRootLastSeenAt: contacts[agentId]?.remotePrekeyRootLastSeenAt,
remotePrekeyRootMismatch: contacts[agentId]?.remotePrekeyRootMismatch,
remotePrekeyPinnedAt: contacts[agentId]?.remotePrekeyPinnedAt,
remotePrekeyLastSeenAt: contacts[agentId]?.remotePrekeyLastSeenAt,
remotePrekeySequence: contacts[agentId]?.remotePrekeySequence,
remotePrekeySignedAt: contacts[agentId]?.remotePrekeySignedAt,
remotePrekeyMismatch: contacts[agentId]?.remotePrekeyMismatch,
remotePrekeyTransparencyHead: contacts[agentId]?.remotePrekeyTransparencyHead,
remotePrekeyTransparencySize: contacts[agentId]?.remotePrekeyTransparencySize,
remotePrekeyTransparencySeenAt: contacts[agentId]?.remotePrekeyTransparencySeenAt,
remotePrekeyTransparencyConflict: contacts[agentId]?.remotePrekeyTransparencyConflict,
remotePrekeyLookupMode: contacts[agentId]?.remotePrekeyLookupMode,
witness_count: contacts[agentId]?.witness_count,
witness_checked_at: contacts[agentId]?.witness_checked_at,
vouch_count: contacts[agentId]?.vouch_count,
vouch_checked_at: contacts[agentId]?.vouch_checked_at,
});
contacts[agentId] = next;
saveContacts(contacts);
@@ -1527,4 +1766,6 @@ export function setDMNotify(on: boolean): void {
storageSet(KEY_DM_NOTIFY, on ? 'true' : 'false');
}
const NODE_ID_PREFIX = '!sb_';
const NODE_ID_HEX_LEN = 16;
const NODE_ID_HEX_LEN = 32;
const NODE_ID_COMPAT_HEX_LEN = 16;
const NODE_ID_LEGACY_HEX_LEN = 8;
+5 -1
View File
@@ -31,13 +31,17 @@ export function currentMailboxEpoch(tsSeconds?: number): number {
export async function mailboxClaimToken(
claimType: 'requests' | 'self',
nodeId: string,
epoch?: number,
): Promise<string> {
const normalizedNodeId = String(nodeId || '').trim();
if (!normalizedNodeId) {
throw new Error('nodeId required for mailbox claim token');
}
const key = await getOrCreateMailboxClaimKey();
const message = new TextEncoder().encode(`sb_mailbox_claim|v1|${claimType}|${normalizedNodeId}`);
const bucket = currentMailboxEpoch(epoch);
const message = new TextEncoder().encode(
`sb_mailbox_claim|v2|${claimType}|${bucket}|${normalizedNodeId}`,
);
const digest = await crypto.subtle.sign('HMAC', key, message);
return bufToHex(digest);
}
+182 -14
View File
@@ -1,4 +1,5 @@
import type { Contact } from '@/mesh/meshIdentity';
import { getContactTrustSummary, rootWitnessIdentityLabel } from '@/mesh/contactTrustSummary';
export type PrivateLaneHint = {
severity: 'warn' | 'danger';
@@ -31,22 +32,68 @@ export function shortTrustFingerprint(fingerprint: string | undefined): string {
return `${value.slice(0, 8)}..${value.slice(-6)}`;
}
export function isInvitePinnedFirstContact(contact?: Partial<Contact> | null): boolean {
return getContactTrustSummary(contact)?.state === 'invite_pinned';
}
export function isFirstContactTrustOnly(contact?: Partial<Contact> | null): boolean {
return getContactTrustSummary(contact)?.state === 'tofu_pinned';
}
export function hasKnownFirstContactAnchor(contact?: Partial<Contact> | null): boolean {
if (!contact) return false;
if (contact.remotePrekeyMismatch || contact.verify_mismatch || contact.verified) return false;
if (contact.verify_registry || contact.verify_inband) return false;
return Boolean(contact.remotePrekeyFingerprint || contact.remotePrekeyPinnedAt);
return Boolean(
contact.dhPubKey ||
contact.sharedAlias ||
contact.remotePrekeyFingerprint ||
contact.remotePrekeyObservedFingerprint ||
contact.remotePrekeyPinnedAt ||
contact.invitePinnedTrustFingerprint ||
contact.invitePinnedDhPubKey ||
contact.invitePinnedAt ||
contact.verified ||
contact.verify_registry ||
contact.verify_inband ||
String(contact.trust_level || '').trim(),
);
}
export function hasVerifiedFirstContactAnchor(contact?: Partial<Contact> | null): boolean {
const summary = getContactTrustSummary(contact);
return Boolean(summary?.verifiedFirstContact);
}
export function requiresVerifiedFirstContact(contact?: Partial<Contact> | null): boolean {
return !hasVerifiedFirstContactAnchor(contact);
}
export function requiresExplicitTofuDowngrade(contact?: Partial<Contact> | null): boolean {
return !hasKnownFirstContactAnchor(contact);
}
export function shouldAutoRevealSasForTrust(contact?: Partial<Contact> | null): boolean {
if (!contact) return false;
const summary = getContactTrustSummary(contact);
if (!summary) return false;
return Boolean(
contact.remotePrekeyMismatch || contact.verify_mismatch || isFirstContactTrustOnly(contact),
summary.state === 'tofu_pinned' ||
summary.state === 'mismatch' ||
summary.state === 'continuity_broken' ||
summary.registryMismatch,
);
}
export function dmTrustPrimaryActionLabel(contact?: Partial<Contact> | null): string {
return isFirstContactTrustOnly(contact) ? 'VERIFY SAS NOW' : 'SHOW SAS';
const action = getContactTrustSummary(contact)?.recommendedAction;
if (action === 'import_invite') {
return 'IMPORT INVITE';
}
if (action === 'verify_sas') {
return 'VERIFY SAS NOW';
}
if (action === 'reverify') {
return 'REVERIFY NOW';
}
return 'SHOW SAS';
}
export function buildPrivateLaneHint(opts: {
@@ -82,25 +129,37 @@ export function buildPrivateLaneHint(opts: {
) {
return {
severity: 'warn',
title: 'TRANSITIONAL PRIVATE LANE',
title: 'CONTROL-ONLY PRIVATE LANE',
detail:
'INFONET gate chat is available, but the strongest transport posture is still warming up. Treat metadata resistance as reduced until Reticulum is ready.',
'Gate chat is available once Wormhole is ready, but this setup is still only PRIVATE / CONTROL_ONLY. Content stays encrypted, while metadata resistance is reduced until a stronger private carrier comes online. Dead Drop / DM remains the stronger lane.',
};
}
return null;
}
export function buildDmTrustHint(contact?: Partial<Contact> | null): DmTrustHint | null {
if (!contact) return null;
if (contact.remotePrekeyMismatch) {
const summary = getContactTrustSummary(contact);
if (!contact || !summary) return null;
const witnessedRootLabel = rootWitnessIdentityLabel(summary);
if (summary.state === 'continuity_broken' || summary.state === 'mismatch') {
return {
severity: 'danger',
title: 'REMOTE PREKEY CHANGED',
title: summary.state === 'continuity_broken' ? 'CONTINUITY BROKEN' : 'REMOTE PREKEY CHANGED',
detail:
'Pause private DM sending. Refresh the contact, compare the SAS phrase or another trusted fingerprint, then explicitly trust the new prekey only if it checks out.',
summary.rootMismatch
? summary.state === 'continuity_broken'
? summary.rootWitnessed
? `A previously trusted contact changed ${witnessedRootLabel}. Pause private DM sending and replace the signed invite or re-verify SAS before trusting the new key.`
: 'A previously trusted contact changed stable root identity. Pause private DM sending and replace the signed invite or re-verify SAS before trusting the new key.'
: summary.rootWitnessed
? `Pause private DM sending. The observed ${witnessedRootLabel} changed; replace the invite or compare SAS before trusting the new key.`
: 'Pause private DM sending. The observed stable root identity changed; replace the invite or compare SAS before trusting the new key.'
: summary.state === 'continuity_broken'
? 'A previously trusted contact changed identity material. Pause private DM sending and replace the invite or re-verify SAS before trusting the new key.'
: 'Pause private DM sending. Refresh the contact, compare the SAS phrase or another trusted fingerprint, then explicitly trust the new prekey only if it checks out.',
};
}
if (contact.verify_mismatch) {
if (summary.registryMismatch) {
return {
severity: 'danger',
title: 'CONTACT KEY MISMATCH',
@@ -108,7 +167,116 @@ export function buildDmTrustHint(contact?: Partial<Contact> | null): DmTrustHint
'Registry and in-band key evidence disagree for this contact. Re-verify before continuing with private messaging.',
};
}
if (isFirstContactTrustOnly(contact)) {
if (summary.legacyLookup && summary.state === 'sas_verified') {
return {
severity: 'warn',
title: 'LEGACY LOOKUP',
detail:
'This contact is SAS verified, but key refresh still relies on direct agent ID lookup. Import or re-import a signed invite to move off stable-ID lookup before removal.',
};
}
if (
summary.rootAttested &&
!summary.rootWitnessed &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'warn',
title: 'ROOT INTERNAL ONLY',
detail:
summary.state === 'invite_pinned'
? 'This contact is anchored to an internal stable root, but not to witnessed root distribution yet. Re-import a current signed invite to refresh stronger root provenance.'
: 'This contact is SAS verified on an internal stable root, but root distribution is not witnessed yet. Re-import a current signed invite if you want witnessed root provenance too.',
};
}
if (
summary.rootDistributionState === 'single_witness' &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'warn',
title: 'ROOT SINGLE WITNESS',
detail:
summary.state === 'invite_pinned'
? 'This contact is anchored to a single-witness stable root. Re-import a current signed invite if you want stronger quorum witness provenance.'
: 'This contact is SAS verified on a single-witness stable root. Re-import a current signed invite if you want stronger quorum witness provenance too.',
};
}
if (
summary.rootWitnessProvenanceState === 'local_quorum' &&
!(summary.rootWitnessed && Number(summary.rootManifestGeneration || 0) > 1 && !summary.rootRotationProven) &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'warn',
title: 'ROOT LOCAL QUORUM',
detail:
summary.state === 'invite_pinned'
? 'This contact is anchored to a locally quorum-witnessed stable root. The current witness policy is satisfied, but those witnesses are still co-resident in one trust domain.'
: 'This contact is SAS verified on a locally quorum-witnessed stable root. The current witness policy is satisfied, but those witnesses are still co-resident in one trust domain.',
};
}
if (
summary.rootWitnessProvenanceState === 'independent_quorum' &&
!(summary.rootWitnessed && Number(summary.rootManifestGeneration || 0) > 1 && !summary.rootRotationProven) &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'warn',
title: 'ROOT INDEPENDENT QUORUM',
detail:
summary.state === 'invite_pinned'
? 'This contact is anchored to an independently quorum-witnessed stable root instead of first-sight TOFU.'
: 'This contact is SAS verified on an independently quorum-witnessed stable root.',
};
}
if (
summary.rootWitnessed &&
Number(summary.rootManifestGeneration || 0) > 1 &&
!summary.rootRotationProven &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'danger',
title: 'ROOT ROTATION UNPROVEN',
detail:
summary.state === 'invite_pinned'
? 'This contact resolves to a witnessed stable root, but the current root replacement does not carry previous-root proof. Replace the signed invite before treating this root as continuous.'
: 'This contact is SAS verified, but the current witnessed root replacement does not carry previous-root proof. Replace the signed invite before treating this root as continuous.',
};
}
if (
summary.rootDistributionState === 'witness_policy_not_met' &&
(summary.state === 'invite_pinned' || summary.state === 'sas_verified')
) {
return {
severity: 'danger',
title: 'ROOT WITNESS POLICY NOT MET',
detail:
summary.state === 'invite_pinned'
? 'This contact resolves to a witnessed stable root, but the current receipt set does not satisfy the published witness policy. Replace or re-import the signed invite before private use.'
: 'This contact is SAS verified, but the current witnessed root no longer satisfies its published witness policy. Replace or re-import the signed invite before private use.',
};
}
if (summary.state === 'invite_pinned') {
return {
severity: 'warn',
title: 'INVITE PINNED',
detail:
summary.rootAttested
? summary.rootWitnessProvenanceState === 'independent_quorum'
? 'This contact was anchored by an imported signed invite and independently quorum-witnessed stable root identity instead of first-sight TOFU. Keep the invite channel trusted, and use SAS if you want an additional continuity check.'
: summary.rootWitnessProvenanceState === 'local_quorum'
? 'This contact was anchored by an imported signed invite and locally quorum-witnessed stable root identity instead of first-sight TOFU. Keep the invite channel trusted, and use SAS if you want an additional continuity check.'
: summary.rootDistributionState === 'single_witness'
? 'This contact was anchored by an imported signed invite and single-witness stable root identity instead of first-sight TOFU. Re-import a current signed invite if you want stronger quorum witness provenance.'
: summary.rootWitnessed
? 'This contact was anchored by an imported signed invite and witnessed stable root identity instead of first-sight TOFU, but the current witness policy is not satisfied.'
: 'This contact was anchored by an imported signed invite and stable root identity instead of first-sight TOFU. Root distribution is still internal-only.'
: 'This contact was anchored by an imported signed invite instead of first-sight TOFU. Keep the invite channel trusted, and use SAS if you want an additional continuity check.',
};
}
if (summary.state === 'tofu_pinned') {
return {
severity: 'warn',
title: 'FIRST CONTACT (TOFU ONLY)',
+20 -3
View File
@@ -83,11 +83,14 @@ export function normalizeStakePayload(payload: Record<string, JsonValue>) {
}
export function normalizeDmKeyPayload(payload: Record<string, JsonValue>) {
return {
const normalized: Record<string, JsonValue> = {
dh_pub_key: String(payload.dh_pub_key ?? ''),
dh_algo: String(payload.dh_algo ?? ''),
timestamp: Number(payload.timestamp ?? 0),
};
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock) normalized.transport_lock = transportLock;
return normalized;
}
export function normalizeDmMessagePayload(payload: Record<string, JsonValue>) {
@@ -112,6 +115,10 @@ export function normalizeDmMessagePayload(payload: Record<string, JsonValue>) {
if (relaySalt) {
normalized.relay_salt = String(relaySalt).trim().toLowerCase();
}
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock) {
normalized.transport_lock = transportLock;
}
return normalized;
}
@@ -130,11 +137,16 @@ function normalizeMailboxClaims(payload: Record<string, JsonValue>) {
}
export function normalizeDmPollPayload(payload: Record<string, JsonValue>) {
return {
const normalized: Record<string, JsonValue> = {
mailbox_claims: normalizeMailboxClaims(payload),
timestamp: Number(payload.timestamp ?? 0),
nonce: String(payload.nonce ?? ''),
};
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock) {
normalized.transport_lock = transportLock;
}
return normalized;
}
export function normalizeDmCountPayload(payload: Record<string, JsonValue>) {
@@ -142,10 +154,15 @@ export function normalizeDmCountPayload(payload: Record<string, JsonValue>) {
}
export function normalizeDmBlockPayload(payload: Record<string, JsonValue>) {
return {
const normalized: Record<string, JsonValue> = {
blocked_id: String(payload.blocked_id ?? ''),
action: String(payload.action ?? 'block').toLowerCase(),
};
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock) {
normalized.transport_lock = transportLock;
}
return normalized;
}
export function normalizeDmKeyWitnessPayload(payload: Record<string, JsonValue>) {
+9 -3
View File
@@ -93,14 +93,20 @@ function bytesToWords(bytes: Uint8Array, count: number): string[] {
return out;
}
export async function deriveSasPhrase(peerId: string, peerDhPub: string, words: number = 8): Promise<string> {
export async function deriveSasPhrase(
peerId: string,
peerDhPub: string,
words: number = 8,
peerRef?: string,
): Promise<string> {
const resolvedPeerRef = String(peerRef || peerId || '').trim();
if (await isWormholeReady()) {
const result = await deriveWormholeSasPhrase(peerId, peerDhPub, words).catch(() => null);
const result = await deriveWormholeSasPhrase(peerId, peerDhPub, words, resolvedPeerRef).catch(() => null);
if (result?.ok && result.phrase) {
return String(result.phrase || '');
}
}
const ctx = sasContext(peerId);
const ctx = sasContext(resolvedPeerRef);
if (!ctx) return '';
const secret = await deriveSharedSecret(peerDhPub);
const digest = await hmacSha256(secret, `sb_sas|v1|${ctx}`);
+16
View File
@@ -91,6 +91,10 @@ function validateDmKey(payload: Record<string, JsonValue>): ValidationResult {
if (!['X25519', 'ECDH', 'ECDH_P256'].includes(algo)) {
return { ok: false, reason: 'Invalid dh_algo' };
}
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock && transportLock !== 'private_strong') {
return { ok: false, reason: 'Invalid transport_lock' };
}
return { ok: true };
}
@@ -111,6 +115,10 @@ function validateDmMessage(payload: Record<string, JsonValue>): ValidationResult
if (deliveryClass === 'shared' && !String(payload.recipient_token ?? '').trim()) {
return { ok: false, reason: 'recipient_token required for shared delivery' };
}
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock && transportLock !== 'private_strong') {
return { ok: false, reason: 'Invalid transport_lock' };
}
return { ok: true };
}
@@ -139,6 +147,10 @@ function validateMailboxClaims(
function validateDmPoll(payload: Record<string, JsonValue>): ValidationResult {
const req = requireFields(payload, ['mailbox_claims', 'timestamp', 'nonce']);
if (!req.ok) return req;
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock && transportLock !== 'private_strong') {
return { ok: false, reason: 'Invalid transport_lock' };
}
return validateMailboxClaims(payload.mailbox_claims);
}
@@ -153,6 +165,10 @@ function validateDmBlock(payload: Record<string, JsonValue>): ValidationResult {
if (!['block', 'unblock'].includes(action)) {
return { ok: false, reason: 'Invalid action' };
}
const transportLock = String(payload.transport_lock ?? '').trim().toLowerCase();
if (transportLock && transportLock !== 'private_strong') {
return { ok: false, reason: 'Invalid transport_lock' };
}
return { ok: true };
}
+90
View File
@@ -0,0 +1,90 @@
/* tslint:disable */
/* eslint-disable */
export function wasm_gate_decrypt(group_handle: bigint, ciphertext: Uint8Array): Uint8Array;
export function wasm_gate_encrypt(group_handle: bigint, plaintext: Uint8Array): Uint8Array;
export function wasm_gate_export_state(identity_handles_json: string, group_handles_json: string): Uint8Array;
export function wasm_gate_import_state(data: Uint8Array): string;
export function wasm_release_group(handle: bigint): boolean;
export function wasm_release_identity(handle: bigint): boolean;
export function wasm_reset_all_state(): boolean;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly privacy_core_add_member: (a: bigint, b: bigint) => bigint;
readonly privacy_core_commit_joined_group_handle: (a: bigint, b: number) => bigint;
readonly privacy_core_commit_message_bytes: (a: number, b: bigint) => void;
readonly privacy_core_commit_welcome_message_bytes: (a: number, b: bigint, c: number) => void;
readonly privacy_core_create_dm_session: (a: bigint, b: bigint) => bigint;
readonly privacy_core_create_group: (a: bigint) => bigint;
readonly privacy_core_create_identity: () => bigint;
readonly privacy_core_decrypt_group_message: (a: number, b: bigint, c: number, d: number) => void;
readonly privacy_core_dm_decrypt: (a: bigint, b: number, c: number, d: number, e: number) => bigint;
readonly privacy_core_dm_encrypt: (a: bigint, b: number, c: number, d: number, e: number) => bigint;
readonly privacy_core_dm_session_welcome: (a: bigint, b: number, c: number) => bigint;
readonly privacy_core_encrypt_group_message: (a: number, b: bigint, c: number, d: number) => void;
readonly privacy_core_export_dm_state: (a: number, b: number) => bigint;
readonly privacy_core_export_gate_state: (a: number, b: number, c: number, d: number, e: number, f: number) => bigint;
readonly privacy_core_export_key_package: (a: number, b: bigint) => void;
readonly privacy_core_export_public_bundle: (a: number, b: bigint) => void;
readonly privacy_core_free_buffer: (a: number) => void;
readonly privacy_core_handle_stats: (a: number, b: number) => bigint;
readonly privacy_core_import_dm_state: (a: number, b: number, c: number, d: number) => bigint;
readonly privacy_core_import_gate_state: (a: number, b: number, c: number, d: number) => bigint;
readonly privacy_core_import_key_package: (a: number, b: number) => bigint;
readonly privacy_core_join_dm_session: (a: bigint, b: number, c: number) => bigint;
readonly privacy_core_last_error_message: (a: number) => void;
readonly privacy_core_release_commit: (a: bigint) => number;
readonly privacy_core_release_dm_session: (a: bigint) => number;
readonly privacy_core_release_group: (a: bigint) => number;
readonly privacy_core_release_identity: (a: bigint) => number;
readonly privacy_core_release_key_package: (a: bigint) => number;
readonly privacy_core_remove_member: (a: bigint, b: number) => bigint;
readonly privacy_core_reset_all_state: () => number;
readonly privacy_core_version: (a: number) => void;
readonly wasm_gate_decrypt: (a: bigint, b: number, c: number) => [number, number, number, number];
readonly wasm_gate_encrypt: (a: bigint, b: number, c: number) => [number, number, number, number];
readonly wasm_gate_export_state: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly wasm_gate_import_state: (a: number, b: number) => [number, number, number, number];
readonly wasm_release_group: (a: bigint) => number;
readonly wasm_release_identity: (a: bigint) => number;
readonly wasm_reset_all_state: () => number;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
@@ -0,0 +1,429 @@
/* @ts-self-types="./privacy_core.d.ts" */
/**
* @param {bigint} group_handle
* @param {Uint8Array} ciphertext
* @returns {Uint8Array}
*/
export function wasm_gate_decrypt(group_handle, ciphertext) {
const ptr0 = passArray8ToWasm0(ciphertext, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wasm_gate_decrypt(group_handle, ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* @param {bigint} group_handle
* @param {Uint8Array} plaintext
* @returns {Uint8Array}
*/
export function wasm_gate_encrypt(group_handle, plaintext) {
const ptr0 = passArray8ToWasm0(plaintext, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wasm_gate_encrypt(group_handle, ptr0, len0);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v2 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v2;
}
/**
* @param {string} identity_handles_json
* @param {string} group_handles_json
* @returns {Uint8Array}
*/
export function wasm_gate_export_state(identity_handles_json, group_handles_json) {
const ptr0 = passStringToWasm0(identity_handles_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(group_handles_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.wasm_gate_export_state(ptr0, len0, ptr1, len1);
if (ret[3]) {
throw takeFromExternrefTable0(ret[2]);
}
var v3 = getArrayU8FromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v3;
}
/**
* @param {Uint8Array} data
* @returns {string}
*/
export function wasm_gate_import_state(data) {
let deferred3_0;
let deferred3_1;
try {
const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.wasm_gate_import_state(ptr0, len0);
var ptr2 = ret[0];
var len2 = ret[1];
if (ret[3]) {
ptr2 = 0; len2 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred3_0 = ptr2;
deferred3_1 = len2;
return getStringFromWasm0(ptr2, len2);
} finally {
wasm.__wbindgen_free(deferred3_0, deferred3_1, 1);
}
}
/**
* @param {bigint} handle
* @returns {boolean}
*/
export function wasm_release_group(handle) {
const ret = wasm.wasm_release_group(handle);
return ret !== 0;
}
/**
* @param {bigint} handle
* @returns {boolean}
*/
export function wasm_release_identity(handle) {
const ret = wasm.wasm_release_identity(handle);
return ret !== 0;
}
/**
* @returns {boolean}
*/
export function wasm_reset_all_state() {
const ret = wasm.wasm_reset_all_state();
return ret !== 0;
}
import * as import1 from "./snippets/mls-rs-core-23c963e7771edd41/inline0.js"
function __wbg_get_imports() {
const import0 = {
__proto__: null,
__wbg___wbindgen_is_function_49868bde5eb1e745: function(arg0) {
const ret = typeof(arg0) === 'function';
return ret;
},
__wbg___wbindgen_is_object_40c5a80572e8f9d3: function(arg0) {
const val = arg0;
const ret = typeof(val) === 'object' && val !== null;
return ret;
},
__wbg___wbindgen_is_string_b29b5c5a8065ba1a: function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
},
__wbg___wbindgen_is_undefined_c0cca72b82b86f4d: function(arg0) {
const ret = arg0 === undefined;
return ret;
},
__wbg___wbindgen_throw_81fc77679af83bc6: function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
},
__wbg_call_d578befcc3145dee: function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); },
__wbg_crypto_38df2bab126b63dc: function(arg0) {
const ret = arg0.crypto;
return ret;
},
__wbg_getRandomValues_c44a50d8cfdaebeb: function() { return handleError(function (arg0, arg1) {
arg0.getRandomValues(arg1);
}, arguments); },
__wbg_length_0c32cb8543c8e4c8: function(arg0) {
const ret = arg0.length;
return ret;
},
__wbg_msCrypto_bd5a034af96bcba6: function(arg0) {
const ret = arg0.msCrypto;
return ret;
},
__wbg_new_with_length_9cedd08484b73942: function(arg0) {
const ret = new Uint8Array(arg0 >>> 0);
return ret;
},
__wbg_node_84ea875411254db1: function(arg0) {
const ret = arg0.node;
return ret;
},
__wbg_process_44c7a14e11e9f69e: function(arg0) {
const ret = arg0.process;
return ret;
},
__wbg_prototypesetcall_3e05eb9545565046: function(arg0, arg1, arg2) {
Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
},
__wbg_randomFillSync_6c25eac9869eb53c: function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1);
}, arguments); },
__wbg_require_b4edbdcf3e2a1ef0: function() { return handleError(function () {
const ret = module.require;
return ret;
}, arguments); },
__wbg_static_accessor_GLOBAL_THIS_a1248013d790bf5f: function() {
const ret = typeof globalThis === 'undefined' ? null : globalThis;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_GLOBAL_f2e0f995a21329ff: function() {
const ret = typeof global === 'undefined' ? null : global;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_SELF_24f78b6d23f286ea: function() {
const ret = typeof self === 'undefined' ? null : self;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_static_accessor_WINDOW_59fd959c540fe405: function() {
const ret = typeof window === 'undefined' ? null : window;
return isLikeNone(ret) ? 0 : addToExternrefTable0(ret);
},
__wbg_subarray_0f98d3fb634508ad: function(arg0, arg1, arg2) {
const ret = arg0.subarray(arg1 >>> 0, arg2 >>> 0);
return ret;
},
__wbg_versions_276b2795b1c6a219: function(arg0) {
const ret = arg0.versions;
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Ref(Slice(U8)) -> NamedExternref("Uint8Array")`.
const ret = getArrayU8FromWasm0(arg0, arg1);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Ref(String) -> Externref`.
const ret = getStringFromWasm0(arg0, arg1);
return ret;
},
__wbindgen_init_externref_table: function() {
const table = wasm.__wbindgen_externrefs;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
table.set(offset + 1, null);
table.set(offset + 2, true);
table.set(offset + 3, false);
},
};
return {
__proto__: null,
"./privacy_core_bg.js": import0,
"./snippets/mls-rs-core-23c963e7771edd41/inline0.js": import1,
};
}
function addToExternrefTable0(obj) {
const idx = wasm.__externref_table_alloc();
wasm.__wbindgen_externrefs.set(idx, obj);
return idx;
}
function getArrayU8FromWasm0(ptr, len) {
ptr = ptr >>> 0;
return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return decodeText(ptr, len);
}
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
function handleError(f, args) {
try {
return f.apply(this, args);
} catch (e) {
const idx = addToExternrefTable0(e);
wasm.__wbindgen_exn_store(idx);
}
}
function isLikeNone(x) {
return x === undefined || x === null;
}
function passArray8ToWasm0(arg, malloc) {
const ptr = malloc(arg.length * 1, 1) >>> 0;
getUint8ArrayMemory0().set(arg, ptr / 1);
WASM_VECTOR_LEN = arg.length;
return ptr;
}
function passStringToWasm0(arg, malloc, realloc) {
if (realloc === undefined) {
const buf = cachedTextEncoder.encode(arg);
const ptr = malloc(buf.length, 1) >>> 0;
getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
WASM_VECTOR_LEN = buf.length;
return ptr;
}
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) {
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) {
if (offset !== 0) {
arg = arg.slice(offset);
}
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
const ret = cachedTextEncoder.encodeInto(arg, view);
offset += ret.written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_externrefs.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
const MAX_SAFARI_DECODE_BYTES = 2146435072;
let numBytesDecoded = 0;
function decodeText(ptr, len) {
numBytesDecoded += len;
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
cachedTextDecoder.decode();
numBytesDecoded = len;
}
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
const cachedTextEncoder = new TextEncoder();
if (!('encodeInto' in cachedTextEncoder)) {
cachedTextEncoder.encodeInto = function (arg, view) {
const buf = cachedTextEncoder.encode(arg);
view.set(buf);
return {
read: arg.length,
written: buf.length
};
};
}
let WASM_VECTOR_LEN = 0;
let wasmModule, wasm;
function __wbg_finalize_init(instance, module) {
wasm = instance.exports;
wasmModule = module;
cachedUint8ArrayMemory0 = null;
wasm.__wbindgen_start();
return wasm;
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
try {
return await WebAssembly.instantiateStreaming(module, imports);
} catch (e) {
const validResponse = module.ok && expectedResponseType(module.type);
if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
} else { throw e; }
}
}
const bytes = await module.arrayBuffer();
return await WebAssembly.instantiate(bytes, imports);
} else {
const instance = await WebAssembly.instantiate(module, imports);
if (instance instanceof WebAssembly.Instance) {
return { instance, module };
} else {
return instance;
}
}
function expectedResponseType(type) {
switch (type) {
case 'basic': case 'cors': case 'default': return true;
}
return false;
}
}
function initSync(module) {
if (wasm !== undefined) return wasm;
if (module !== undefined) {
if (Object.getPrototypeOf(module) === Object.prototype) {
({module} = module)
} else {
console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
}
}
const imports = __wbg_get_imports();
if (!(module instanceof WebAssembly.Module)) {
module = new WebAssembly.Module(module);
}
const instance = new WebAssembly.Instance(module, imports);
return __wbg_finalize_init(instance, module);
}
async function __wbg_init(module_or_path) {
if (wasm !== undefined) return wasm;
if (module_or_path !== undefined) {
if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
({module_or_path} = module_or_path)
} else {
console.warn('using deprecated parameters for the initialization function; pass a single object instead')
}
}
if (module_or_path === undefined) {
module_or_path = new URL('privacy_core_bg.wasm', import.meta.url);
}
const imports = __wbg_get_imports();
if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
module_or_path = fetch(module_or_path);
}
const { instance, module } = await __wbg_load(await module_or_path, imports);
return __wbg_finalize_init(instance, module);
}
export { initSync, __wbg_init as default };
@@ -0,0 +1,49 @@
/* tslint:disable */
/* eslint-disable */
export const memory: WebAssembly.Memory;
export const privacy_core_add_member: (a: bigint, b: bigint) => bigint;
export const privacy_core_commit_joined_group_handle: (a: bigint, b: number) => bigint;
export const privacy_core_commit_message_bytes: (a: number, b: bigint) => void;
export const privacy_core_commit_welcome_message_bytes: (a: number, b: bigint, c: number) => void;
export const privacy_core_create_dm_session: (a: bigint, b: bigint) => bigint;
export const privacy_core_create_group: (a: bigint) => bigint;
export const privacy_core_create_identity: () => bigint;
export const privacy_core_decrypt_group_message: (a: number, b: bigint, c: number, d: number) => void;
export const privacy_core_dm_decrypt: (a: bigint, b: number, c: number, d: number, e: number) => bigint;
export const privacy_core_dm_encrypt: (a: bigint, b: number, c: number, d: number, e: number) => bigint;
export const privacy_core_dm_session_welcome: (a: bigint, b: number, c: number) => bigint;
export const privacy_core_encrypt_group_message: (a: number, b: bigint, c: number, d: number) => void;
export const privacy_core_export_dm_state: (a: number, b: number) => bigint;
export const privacy_core_export_gate_state: (a: number, b: number, c: number, d: number, e: number, f: number) => bigint;
export const privacy_core_export_key_package: (a: number, b: bigint) => void;
export const privacy_core_export_public_bundle: (a: number, b: bigint) => void;
export const privacy_core_free_buffer: (a: number) => void;
export const privacy_core_handle_stats: (a: number, b: number) => bigint;
export const privacy_core_import_dm_state: (a: number, b: number, c: number, d: number) => bigint;
export const privacy_core_import_gate_state: (a: number, b: number, c: number, d: number) => bigint;
export const privacy_core_import_key_package: (a: number, b: number) => bigint;
export const privacy_core_join_dm_session: (a: bigint, b: number, c: number) => bigint;
export const privacy_core_last_error_message: (a: number) => void;
export const privacy_core_release_commit: (a: bigint) => number;
export const privacy_core_release_dm_session: (a: bigint) => number;
export const privacy_core_release_group: (a: bigint) => number;
export const privacy_core_release_identity: (a: bigint) => number;
export const privacy_core_release_key_package: (a: bigint) => number;
export const privacy_core_remove_member: (a: bigint, b: number) => bigint;
export const privacy_core_reset_all_state: () => number;
export const privacy_core_version: (a: number) => void;
export const wasm_gate_decrypt: (a: bigint, b: number, c: number) => [number, number, number, number];
export const wasm_gate_encrypt: (a: bigint, b: number, c: number) => [number, number, number, number];
export const wasm_gate_export_state: (a: number, b: number, c: number, d: number) => [number, number, number, number];
export const wasm_gate_import_state: (a: number, b: number) => [number, number, number, number];
export const wasm_release_group: (a: bigint) => number;
export const wasm_release_identity: (a: bigint) => number;
export const wasm_reset_all_state: () => number;
export const __wbindgen_exn_store: (a: number) => void;
export const __externref_table_alloc: () => number;
export const __wbindgen_externrefs: WebAssembly.Table;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_start: () => void;
@@ -0,0 +1,4 @@
export function date_now() {
return Date.now();
}
+2 -1
View File
@@ -31,9 +31,10 @@ export function requiresSenderRecovery(
if (isCanonicalReducedRequestEnvelope(message)) {
return true;
}
const senderId = String(message.sender_id || '').trim();
return Boolean(
String(message.sender_seal || '').trim() &&
String(message.sender_id || '').trim().startsWith('sealed:'),
(senderId.startsWith('sealed:') || senderId.startsWith('sender_token:')),
);
}
+69 -1
View File
@@ -1,5 +1,6 @@
import { API_BASE } from '@/lib/api';
import { controlPlaneFetch } from '@/lib/controlPlane';
import { controlPlaneFetch, controlPlaneJson } from '@/lib/controlPlane';
import type { LegacyCompatibilitySnapshot } from '@/mesh/wormholeCompatibility';
export interface WormholeState {
installed: boolean;
@@ -30,6 +31,57 @@ export interface WormholeState {
recent_private_clearnet_fallback?: boolean;
recent_private_clearnet_fallback_at?: number;
recent_private_clearnet_fallback_reason?: string;
clearnet_fallback_policy?: string;
clearnet_fallback_requested?: string;
private_delivery?: PrivateDeliverySummary;
legacy_compatibility?: LegacyCompatibilitySnapshot;
}
export interface PrivateDeliveryApprovalAction {
code: 'wait' | 'relay';
label: string;
emphasis: 'primary' | 'secondary' | '';
}
export interface PrivateDeliveryApprovalState {
required?: boolean;
reason_code?: string;
started_at?: number;
window_seconds?: number;
status_label?: string;
detail?: string;
actions?: PrivateDeliveryApprovalAction[];
}
export interface PrivateDeliveryItem {
id: string;
lane: string;
release_state: string;
required_tier?: string;
current_tier?: string;
status?: {
code?: string;
label?: string;
reason_code?: string;
reason?: string;
};
approval?: PrivateDeliveryApprovalState;
}
export interface PrivateDeliverySummary {
pending_count?: number;
preparing_count?: number;
queued_count?: number;
approval_required_count?: number;
current_tier?: string;
items?: PrivateDeliveryItem[];
}
export interface PrivateDeliveryActionResponse {
ok: boolean;
action: 'wait' | 'relay';
item?: PrivateDeliveryItem;
detail?: string;
}
export interface WormholeSettingsSnapshot {
@@ -174,6 +226,22 @@ export async function connectWormhole(): Promise<WormholeState> {
return state;
}
export async function updatePrivateDeliveryAction(
itemId: string,
action: 'wait' | 'relay',
): Promise<PrivateDeliveryActionResponse> {
const response = await controlPlaneJson<PrivateDeliveryActionResponse>(
`/api/wormhole/private-delivery/${encodeURIComponent(itemId)}/action`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
},
);
invalidateWormholeRuntimeCache();
return response;
}
export async function disconnectWormhole(): Promise<WormholeState> {
resetWormholeCaches();
const res = await controlPlaneFetch('/api/wormhole/disconnect', {
+131
View File
@@ -0,0 +1,131 @@
export interface LegacyCompatibilitySunsetEntry {
target_version?: string;
target_date?: string;
status?: string;
block_env?: string;
blocked?: boolean;
}
export interface LegacyCompatibilityUsageBucket {
count?: number;
blocked_count?: number;
last_seen_at?: number;
recent_targets?: Array<Record<string, unknown>>;
}
export interface LegacyCompatibilitySnapshot {
sunset?: {
legacy_node_id_binding?: LegacyCompatibilitySunsetEntry;
legacy_agent_id_lookup?: LegacyCompatibilitySunsetEntry;
};
usage?: {
legacy_node_id_binding?: LegacyCompatibilityUsageBucket;
legacy_agent_id_lookup?: LegacyCompatibilityUsageBucket;
};
}
export interface LegacyCompatibilitySummaryItem {
key: 'legacy_node_id_binding' | 'legacy_agent_id_lookup';
label: string;
blocked: boolean;
count: number;
blockedCount: number;
lastSeenAt: number;
targetVersion: string;
targetDate: string;
recentTargets: string[];
}
function safeInt(value: unknown): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function shortId(value: unknown): string {
const text = String(value || '').trim().toLowerCase();
if (!text) return 'unknown';
if (text.length <= 14) return text;
return `${text.slice(0, 10)}...`;
}
function normalizeKinds(value: unknown): string {
const items = Array.isArray(value)
? value
.map((item) => String(item || '').trim().toLowerCase())
.filter(Boolean)
: [];
return items.length ? items.join(', ') : 'compat';
}
function formatRecentTargets(
key: LegacyCompatibilitySummaryItem['key'],
entries: Array<Record<string, unknown>> | undefined,
): string[] {
const normalized = Array.isArray(entries) ? entries : [];
if (key === 'legacy_node_id_binding') {
return normalized
.slice(0, 2)
.map((entry) => `${shortId(entry.node_id)} -> ${shortId(entry.current_node_id)}`);
}
return normalized
.slice(0, 2)
.map((entry) => `${shortId(entry.agent_id)} (${normalizeKinds(entry.lookup_kinds)})`);
}
export function formatLegacyCompatibilitySeenAt(timestamp: number): string {
if (!timestamp) return 'never';
try {
return new Date(timestamp * 1000).toISOString().replace('T', ' ').slice(0, 16) + 'Z';
} catch {
return 'never';
}
}
export function summarizeLegacyCompatibility(
snapshot: LegacyCompatibilitySnapshot | null | undefined,
): LegacyCompatibilitySummaryItem[] {
const current = snapshot || {};
const nodeSunset = current.sunset?.legacy_node_id_binding || {};
const lookupSunset = current.sunset?.legacy_agent_id_lookup || {};
const nodeUsage = current.usage?.legacy_node_id_binding || {};
const lookupUsage = current.usage?.legacy_agent_id_lookup || {};
return [
{
key: 'legacy_node_id_binding',
label: 'Legacy node-ID compat',
blocked: Boolean(nodeSunset.blocked),
count: safeInt(nodeUsage.count),
blockedCount: safeInt(nodeUsage.blocked_count),
lastSeenAt: safeInt(nodeUsage.last_seen_at),
targetVersion: String(nodeSunset.target_version || '').trim() || 'n/a',
targetDate: String(nodeSunset.target_date || '').trim() || 'n/a',
recentTargets: formatRecentTargets(
'legacy_node_id_binding',
nodeUsage.recent_targets,
),
},
{
key: 'legacy_agent_id_lookup',
label: 'Legacy agent lookup',
blocked: Boolean(lookupSunset.blocked),
count: safeInt(lookupUsage.count),
blockedCount: safeInt(lookupUsage.blocked_count),
lastSeenAt: safeInt(lookupUsage.last_seen_at),
targetVersion: String(lookupSunset.target_version || '').trim() || 'n/a',
targetDate: String(lookupSunset.target_date || '').trim() || 'n/a',
recentTargets: formatRecentTargets(
'legacy_agent_id_lookup',
lookupUsage.recent_targets,
),
},
];
}
export function hasLegacyCompatibilityActivity(
snapshot: LegacyCompatibilitySnapshot | null | undefined,
): boolean {
return summarizeLegacyCompatibility(snapshot).some(
(item) => item.count > 0 || item.blockedCount > 0,
);
}
File diff suppressed because it is too large Load Diff