export const invitationProgress = ['home', 'practitioner', 'account-link', 'admin-role', 'platform-role', 'activate-admin', 'activate-platform', 'patients-group', 'admins-group', 'platform-group', 'membership', 'evidence', 'completed'] as const; export interface InvitationMembership { stage: 'admin-active' | 'complete'; confirmedAt: string; practitionerId: string; practitionerRoleId: string; platformManagerRoleId: string; } export interface PracticeInvitation { version?: 1; organizationId: string; organizationName: string; status: 'pending' | 'accepted' | 'declined'; createdAt: string; instance?: string; operationId?: string; progress?: typeof invitationProgress[number]; outcome?: 'continuation-required' | 'membership-confirmed' | 'completed' | 'held'; membership?: InvitationMembership; currentStanding?: 'unobserved' | 'active' | 'changed' | 'unavailable'; reason?: 'legacy-assistance' | 'authority-unavailable' | 'operation-unconfirmed'; } const object = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 2048 && value.trim() === value; const id = (value: unknown): value is string => text(value) && /^[A-Za-z0-9.-]{1,64}$/.test(value); const instance = (value: unknown): value is string => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); export function decodePracticeInvitation(value: unknown, legacyPending = false): PracticeInvitation { if (!object(value) || !id(value.organizationId) || !text(value.organizationName) || typeof value.status !== 'string' || !['pending', 'accepted', 'declined'].includes(value.status) || !text(value.createdAt) || !Number.isFinite(Date.parse(value.createdAt))) throw new Error('Invitation proof is unavailable'); if (legacyPending && value.version === undefined && value.status === 'pending') return { organizationId: value.organizationId, organizationName: value.organizationName, status: 'pending', createdAt: value.createdAt, }; if (value.version !== 1 || !instance(value.instance)) throw new Error('Invitation instance proof is unavailable'); const result: PracticeInvitation = { version: 1, organizationId: value.organizationId, organizationName: value.organizationName, status: value.status as PracticeInvitation['status'], createdAt: value.createdAt, instance: value.instance }; if (value.operationId !== undefined) { if (value.status !== 'accepted' || !id(value.operationId) || !invitationProgress.includes(value.progress as typeof invitationProgress[number]) || typeof value.outcome !== 'string' || !['continuation-required', 'membership-confirmed', 'completed', 'held'].includes(value.outcome) || typeof value.currentStanding !== 'string' || !['unobserved', 'active', 'changed', 'unavailable'].includes(value.currentStanding)) throw new Error('Original invitation operation is unavailable'); Object.assign(result, { operationId: value.operationId, progress: value.progress, outcome: value.outcome, currentStanding: value.currentStanding }); } else if (value.status === 'accepted') { if (value.outcome !== 'held' || value.reason !== 'legacy-assistance') throw new Error('Accepted decision has no membership proof'); Object.assign(result, { outcome: 'held', reason: 'legacy-assistance', currentStanding: 'unobserved' }); } else if (value.status === 'pending' && value.outcome !== undefined) { if (value.outcome !== 'held' || value.reason !== 'authority-unavailable' || value.currentStanding !== 'unavailable') throw new Error('Pending invitation standing is unavailable'); Object.assign(result, { outcome: 'held', reason: 'authority-unavailable', currentStanding: 'unavailable' }); } if (value.membership !== undefined) { const proof = value.membership; if (!object(proof) || typeof proof.stage !== 'string' || !['admin-active', 'complete'].includes(proof.stage) || !text(proof.confirmedAt) || !Number.isFinite(Date.parse(proof.confirmedAt)) || ![proof.practitionerId, proof.practitionerRoleId, proof.platformManagerRoleId].every(id) || !result.operationId || !['membership-confirmed', 'completed'].includes(String(result.outcome))) throw new Error('Invitation membership is unconfirmed'); result.membership = { stage: proof.stage as InvitationMembership['stage'], confirmedAt: proof.confirmedAt, practitionerId: proof.practitionerId as string, practitionerRoleId: proof.practitionerRoleId as string, platformManagerRoleId: proof.platformManagerRoleId as string }; } if (result.outcome === 'completed' && (!result.membership || result.membership.stage !== 'complete' || result.progress !== 'completed')) throw new Error('Invitation completion is unconfirmed'); if (result.outcome === 'membership-confirmed' && !result.membership) throw new Error('Invitation membership is unconfirmed'); if (value.reason !== undefined) { if (typeof value.reason !== 'string' || !['legacy-assistance', 'authority-unavailable', 'operation-unconfirmed'].includes(value.reason)) throw new Error('Invitation standing is unavailable'); result.reason = value.reason as PracticeInvitation['reason']; } return result; } export interface InvitationMarker { version: 1; accountId: string; installation: string; organizationId: string; instance: string; opening: string; attempt: string; operationId?: string; membership?: InvitationMembership; state: 'unsent' | 'unconfirmed' | 'known' | 'settled'; } export const invitationMarkerKey = (account: string, installation: string, organization: string, invitationInstance: string): string => 'akashic.invitation.operation.' + encodeURIComponent(JSON.stringify([account, installation, organization, invitationInstance])); const decodeMarker = (raw: string, account: string, installation: string, organization: string, invitationInstance: string): InvitationMarker | 'unavailable' => { const value: unknown = JSON.parse(raw); if (!object(value) || value.version !== 1 || value.accountId !== account || value.installation !== installation || value.organizationId !== organization || value.instance !== invitationInstance || !id(value.opening) || !id(value.attempt) || typeof value.state !== 'string' || !['unsent', 'unconfirmed', 'known', 'settled'].includes(value.state) || value.operationId !== undefined && !id(value.operationId) || value.state === 'unsent' && value.operationId !== undefined || value.state === 'known' && !id(value.operationId)) return 'unavailable'; if (value.membership !== undefined) { const membership = value.membership; if (!id(value.operationId) || !object(membership) || typeof membership.stage !== 'string' || !['admin-active', 'complete'].includes(membership.stage) || !text(membership.confirmedAt) || !Number.isFinite(Date.parse(membership.confirmedAt)) || ![membership.practitionerId, membership.practitionerRoleId, membership.platformManagerRoleId].every(id)) return 'unavailable'; } return value as unknown as InvitationMarker; }; export function readInvitationMarker(account: string, installation: string, organization: string, invitationInstance: string): InvitationMarker | undefined | 'unavailable' { try { const key = invitationMarkerKey(account, installation, organization, invitationInstance); const raw = localStorage.getItem(key); const pointer = raw === null ? undefined : decodeMarker(raw, account, installation, organization, invitationInstance); if (pointer === 'unavailable' || pointer && pointer.state !== 'settled') return pointer; let retained: InvitationMarker | undefined = pointer; if (localStorage.length > 4096) return 'unavailable'; for (let index = 0; index < localStorage.length; index++) { const name = localStorage.key(index); if (!name?.startsWith(key + '.attempt.')) continue; const content = localStorage.getItem(name); if (content === null) return 'unavailable'; const record = decodeMarker(content, account, installation, organization, invitationInstance); if (record === 'unavailable') return record; if (pointer?.state === 'settled' && record.state === 'unsent') continue; // A proved terminal decision fences unadmitted attempts of this exact instance. if (pointer?.state === 'settled' && !record.operationId && !record.membership) continue; if (pointer?.state === 'settled' && pointer.operationId && pointer.operationId === record.operationId && (!record.membership || pointer.membership && record.membership.practitionerId === pointer.membership.practitionerId && record.membership.practitionerRoleId === pointer.membership.practitionerRoleId && record.membership.platformManagerRoleId === pointer.membership.platformManagerRoleId)) continue; if (record.state !== 'settled') return record; retained = record; } return retained; } catch { return 'unavailable'; } } export function retainedInvitationMarkers(account: string, installation: string): InvitationMarker[] { try { if (localStorage.length > 4096) return []; const retained = new Map(); const seen = new Set(); for (let index = 0; index < localStorage.length; index++) { const key = localStorage.key(index); if (!key?.startsWith('akashic.invitation.operation.')) continue; const raw = localStorage.getItem(key); if (!raw) continue; let value: unknown; try { value = JSON.parse(raw); } catch { continue; } if (!object(value) || value.accountId !== account || value.installation !== installation || !id(value.organizationId) || !instance(value.instance)) continue; const identity = value.organizationId + ':' + value.instance; if (seen.has(identity)) continue; seen.add(identity); const marker = readInvitationMarker(account, installation, value.organizationId, value.instance); if (marker && marker !== 'unavailable' && marker.state !== 'settled') retained.set(marker.organizationId + ':' + marker.instance, marker); } return [...retained.values()]; } catch { return []; } } export function writeInvitationMarker(value: InvitationMarker, expectedAttempt?: string): boolean { try { const current = readInvitationMarker(value.accountId, value.installation, value.organizationId, value.instance); if (current === 'unavailable' || (expectedAttempt === undefined ? current !== undefined : current?.attempt !== expectedAttempt)) return false; const key = invitationMarkerKey(value.accountId, value.installation, value.organizationId, value.instance); const serialized = JSON.stringify(value); // The pointer is advisory; one opening cannot erase another attempt's retained work. localStorage.setItem(key + '.attempt.' + value.attempt, serialized); if (localStorage.getItem(key + '.attempt.' + value.attempt) !== serialized) return false; localStorage.setItem(key, serialized); return localStorage.getItem(key) === serialized; } catch { return false; } }