mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-07-30 23:47:27 +02:00
release: prepare v0.9.7
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
Binary file not shown.
@@ -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();
|
||||
}
|
||||
@@ -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:')),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user