diff --git a/docs/planning/SHARED_GUARDIANSHIP_GOVERNANCE.md b/docs/planning/SHARED_GUARDIANSHIP_GOVERNANCE.md new file mode 100644 index 00000000..6e4f5ef8 --- /dev/null +++ b/docs/planning/SHARED_GUARDIANSHIP_GOVERNANCE.md @@ -0,0 +1,105 @@ +# Shared Guardianship Governance and Release Contract + +**Version:** 1.0 +**Date:** 31 August 2026 +**Owner:** Tech Evolve AI +**Applies to:** Shared Guardianship MVP + +## Authority Boundary + +Shared Guardianship creates one canonical server creature used by two verified +guardians. Neither local save is authoritative. Clients may cache a projection, +but every mutation is an authenticated bounded command checked by the server. + +The feature is unavailable unless all of these gates are true: + +1. the player selected an eligible `16 to 17` or `18 or older` age range; +2. Cloud Save is enabled and its latest save is committed; +3. the Supabase session is permanent, not anonymous; +4. the email identity is verified; +5. the identity has a password credential for recovery and deletion confirmation; +6. both guardians accepted the current consent and privacy versions; +7. production manual identity linking and email delivery were smoke tested; +8. the release feature flag is enabled. + +Solo play, local saving and ordinary Fusion remain usable if any gate is false. + +## Privacy and Safety Rules + +- Collect only the email needed by Supabase Auth. Never copy it into game tables, + event records, telemetry, invitations or peer-visible payloads. +- Do not add chat, free-form messages, public discovery, searchable profiles, + contact import, location, online status or behavioural advertising. +- Invitation payloads expose only bounded creature traits and neutral guardian + labels. +- Under-16 profiles cannot create, join, inspect or receive a shared invitation. +- Age is self-attested and must be described honestly; it is not verified age. +- No absence, missed action or departure can injure or delete the creature. +- Either guardian can leave. One guardian cannot remove the other, transfer the + creature, expose the other guardian's identity or erase shared history. +- Account deletion revokes participation and removes unnecessary attribution. + Immutable parentage retains only non-identifying lineage fingerprints. +- Permanent account deletion requires explicit typed confirmation and a password + sign-in no more than five minutes before the server command. + +## Data Retention + +| Record | Retention | +|---|---| +| Unjoined invitation | Until expiry, then no more than 30 days for abuse investigation | +| Cancelled invitation | No more than 30 days | +| Join-attempt rate-limit record | No more than 24 hours | +| Canonical shared creature | While at least one guardian retains it | +| Care events | Latest 100 player-visible events; bounded security records follow service policy | +| Revoked participant | User ID removed or one-way pseudonymised when no longer needed | +| Email and credential | Supabase Auth only, until account deletion | + +## Threat Model + +| Threat | Required control | +|---|---| +| Guessing invitation codes | 48-bit private codes, expiry, single use, per-user rate limits | +| Self-pairing | Reject identical authenticated user IDs | +| Anonymous bypass | Server checks JWT `is_anonymous=false` on every entry and mutation | +| Forged parent | Resolve an eligible parent from the caller's committed cloud save | +| Consent replay | Bind consent to invitation, parent fingerprint and terms version | +| Duplicate child | Unique invitation constraint plus idempotent atomic commit | +| Lost update | Expected revision and one server transaction per command | +| Unauthorized read | Participant-only security-definer projections; direct table access revoked | +| Revoked user replay | Active-participant check inside every command transaction | +| Peer identity exposure | Never return email, provider, user ID or save data | +| Account takeover | Verified email, password policy, reset flow and Supabase session controls | +| Accidental account deletion | Typed deletion phrase, recent password proof and server-side recency check | +| Service outage | Last safe cache remains visible; solo game never waits on shared services | + +## Change Control + +The following changes require a new privacy and safeguarding review before code +is enabled: lowering the age boundary, adding communication, public discovery, +more guardians, custody transfer, synchronous multiplayer, monetisation tied to +care, precise presence, user-written names visible to a peer, or story decisions +that affect another guardian. + +Database migrations are additive. Existing sibling Fusion records are not +rewritten. The release flag may hide new entry points without deleting canonical +data. Rollback restores the previous client while server records remain intact. + +## Release Evidence + +Production activation requires all of the following, bound to one source commit: + +- migration and RLS allow/deny tests; +- anonymous-user denial tests; +- two permanent-account invitation, consent and one-child proof; +- identical child ID/genome/revision on two sessions; +- action A to B and action B to offline/reconnected A convergence; +- duplicate, stale and simultaneous command tests; +- non-participant and revoked-participant denial; +- departure and account-deletion cleanup; +- 390x844 and desktop invitation/consent/habitat UI review; +- zero direct identifiers in peer payloads or observability; +- manual-linking and production email delivery smoke; +- normal build, full tests and protected release workflow. + +No test account credentials, email addresses or invitation codes are committed +to source control or retained in screenshots. diff --git a/docs/planning/SHARED_GUARDIANSHIP_PRD.md b/docs/planning/SHARED_GUARDIANSHIP_PRD.md new file mode 100644 index 00000000..0beb9d4d --- /dev/null +++ b/docs/planning/SHARED_GUARDIANSHIP_PRD.md @@ -0,0 +1,526 @@ +# PRD: Shared Guardianship +## One Persistent Creature Across Two Sanctuaries + +**Version:** 1.0 +**Date:** 31 August 2026 +**Status:** Approved MVP contract; implementation authorized +**Product name:** Shared Guardianship +**Related research:** `docs/planning/SHARED_CREATURE_CO_PARENTING_RESEARCH.md` + +--- + +## Executive Summary + +Shared Guardianship allows two trusted players to each contribute one eligible +creature to Fusion and become guardians of one newly born creature. The child +is one server-authoritative life, not two copied creatures and not an object +passed between devices. + +Both guardians can always see and care for the same creature from their own +Sanctuary. Actions synchronize through a shared record. The MVP focuses on the +birth, shared presence, ordinary care, visible contribution history and safe +cross-device continuity. Story events, synchronous cooperative gameplay, +chat, public discovery and competitive mechanics are explicitly deferred. + +The feature should feel like shared responsibility without creating pressure, +punishment or dependence on the other player being online. + +--- + +## Product Opportunity + +Most multiplayer creature games use trading, battling or duplicated pets. +Mythical Void can offer a more emotionally distinctive relationship: two +people care for one persistent alien life whose genetics visibly came from +both of their creatures. + +The feature can create: + +- a meaningful invitation reason rather than a generic referral; +- a memorable two-player Fusion and hatching reveal; +- recurring return behavior based on curiosity and cooperation; +- a visible record of both guardians helping the creature; +- a foundation for later shared expeditions and story moments without making + those systems part of this MVP. + +--- + +## Product Principles + +1. **One life, two windows:** both players see projections of one canonical + creature record. +2. **Always available:** the creature is never checked out, transferred or + made unavailable because the other guardian is playing. +3. **Asynchronous first:** either guardian can play normally without arranging + a simultaneous session. +4. **No guilt mechanics:** absence cannot injure, abandon, remove or permanently + disadvantage the creature. +5. **Visible contribution:** the interface shows what changed and who helped, + without exposing personal information. +6. **Consent at irreversible moments:** both players approve Fusion and the + initial shared name. Routine care does not require dual confirmation. +7. **Private by design:** trusted invitations only, with no chat, public player + search, social graph or discoverable profiles. +8. **Solo game remains complete:** Shared Guardianship enhances Mythical Void; + it is never required to finish the game or obtain core progression. + +--- + +## Goals + +### MVP Goals + +- Let two eligible players privately connect using an expiring invitation. +- Let each player contribute one eligible adult creature with informed consent. +- Generate exactly one deterministic child with truthful inherited genetics. +- Grant both players continuing access to that same child. +- Show the shared creature in both Sanctuaries at all times. +- Synchronize name, genetics, lifecycle, needs, care, bond and history safely. +- Let either guardian complete ordinary care independently. +- Show concise, friendly updates about the other guardian's contributions. +- Recover correctly after retries, offline play, stale clients and device changes. +- Let either guardian leave without deleting or holding the creature hostage. + +### Success Definition + +An unfamiliar eligible player can invite a trusted second player, understand +that they are creating one shared creature, complete Fusion, see the same child +on both devices, care for it independently and observe the resulting state on +the other device without coaching or contradictory data. + +--- + +## Non-Goals + +The MVP does not include: + +- story-event integration or shared campaign decisions; +- real-time cooperative levels, combat or simultaneous creature control; +- chat, voice, free-form peer messaging or image sharing; +- public matchmaking, player search, friend lists or profiles; +- creature trading, selling, gifting or custody transfer; +- breeding the shared child again; +- more than two guardians; +- guardian-versus-guardian competition, scoring or care streak comparison; +- location, contacts, social graph or behavioral advertising; +- under-16 access to the connected feature; +- a custom recovery-code identity system; +- generated video as a dependency for Fusion or care. + +--- + +## Target Users + +### Primary + +- Trusted real-world pairs aged 16 or older: siblings, friends, partners, + parent and older child, or family members living apart. +- Existing Mythical Void players who have each raised at least one eligible + adult creature and understand ordinary care and Fusion. + +### Excluded From MVP + +- Players marked under 16. +- Anonymous-only players who cannot recover a durable account on another + device. +- Unknown players seeking matchmaking or public social interaction. + +--- + +## Core Player Journey + +### 1. Unlock + +Shared Guardianship appears in the Fusion Pod only when the player: + +- is eligible for connected features; +- has a verified durable identity; +- owns an eligible adult creature; +- has completed the ordinary solo Fusion explanation; +- has no conflicting active shared-Fusion operation. + +The locked state explains the next requirement in plain language. + +### 2. Create Private Invitation + +The host selects one eligible creature and creates a short-lived private +invitation. The game displays a code or link and a clear warning to share it +only with someone they know. + +The invitation contains no public player profile, creature custom name, email +or other personal information. It expires automatically and can be cancelled. + +### 3. Join and Select + +The second player opens the invitation, signs in if required, selects one +eligible creature and reviews the proposed pairing. Both players see bounded +creature facts needed for consent: classification, visible genetic traits, +generation, lifecycle stage and compatibility. Private save data remains +hidden. + +### 4. Independent Consent + +Each player independently confirms: + +- the selected parent creature; +- that one shared child will be created; +- that both guardians will always have access; +- that routine actions by either guardian affect the same creature; +- the departure and single-guardian policy. + +Fusion creates nothing unless both current consents are valid. + +### 5. Shared Fusion and Reveal + +One atomic server operation locks the two parent snapshots, generates one +deterministic genome, creates one child and grants access to both guardians. +Both devices receive the same creature ID, genetics and reveal state. + +If one player disconnects, the committed child remains safe and the reveal is +resumable. A retry cannot create a second child. + +### 6. Shared Naming + +Each guardian chooses or proposes from a moderated generated list. A name is +committed when both approve the same option. Until then, the creature uses a +neutral temporary classification. Free-form messages between players are not +part of naming. + +### 7. Everyday Shared Care + +The shared creature is visible in a dedicated Sanctuary habitat on both +devices. Either guardian can perform bounded actions such as feeding, resting, +playing, grooming or gathering a requested material. + +The result is committed once to the canonical creature and appears on both +devices. The other player's presence is not required. + +### 8. Contribution History + +The habitat shows a small chronological record using guardian labels selected +from safe defaults, for example: + +- `Guardian A gathered crystal moss.` +- `Guardian B helped the creature rest.` +- `The creature discovered a new response.` + +Do not display email, account name, location, precise activity time or private +save details. + +### 9. Departure + +Either guardian can leave after a clear confirmation. Their access is removed, +the creature remains with the continuing guardian and no progress is lost. +There is no unilateral delete, transfer, sale or hostage state. + +--- + +## Functional Requirements + +### Identity and Eligibility + +| ID | Requirement | +|---|---| +| SG-001 | Both participants must pass the current connected-feature age boundary. | +| SG-002 | Both participants must use durable, verified identities before the child is committed. | +| SG-003 | Anonymous progress may begin the invitation explanation but cannot complete shared Fusion. | +| SG-004 | A participant can have a configurable maximum number of active shared creatures; MVP default is one. | +| SG-005 | The system must prevent a player from inviting themselves through a second session. | + +### Invitation and Consent + +| ID | Requirement | +|---|---| +| SG-010 | Invitations must be private, single-use, revocable and expire within a bounded period. | +| SG-011 | Joining must reveal no direct identifier or unrelated save data. | +| SG-012 | Both players must confirm the exact parent fingerprint and current terms version. | +| SG-013 | Changing either parent after consent invalidates both consents. | +| SG-014 | Fusion must commit the child and both participant grants atomically or create nothing. | +| SG-015 | Repeating the same operation must return the original result, never another child. | + +### Shared Creature + +| ID | Requirement | +|---|---| +| SG-020 | One immutable shared-creature ID must represent the child on every device. | +| SG-021 | Both guardians must see the same genome, name, lifecycle, care state and history revision. | +| SG-022 | The creature must remain visible and interactable on both devices; it is never passed between them. | +| SG-023 | Genetics must be derived deterministically from the protected parent snapshots using the production Breeding Engine contract. | +| SG-024 | The interface must clearly label the creature as shared without making it look unavailable when the other guardian is active. | +| SG-025 | Generated portrait or video failures must never block reveal, care or synchronization. | + +### Care and Synchronization + +| ID | Requirement | +|---|---| +| SG-030 | Every mutation must be a bounded server command with actor, idempotency key and expected revision. | +| SG-031 | Successful commands increment one monotonic creature revision. | +| SG-032 | Duplicate commands return the original committed result. | +| SG-033 | Stale commands return the newest projection and never overwrite later state. | +| SG-034 | Simultaneous compatible actions resolve in server order. | +| SG-035 | Mutually exclusive actions reject one command with a friendly refresh state. | +| SG-036 | Offline actions remain proposals until accepted by the server; clients cannot declare them canonical. | +| SG-037 | MVP clients poll the participant-scoped projection every three seconds; a future private invalidation message may contain only creature ID, revision and event kind. | +| SG-038 | Losing connectivity must not freeze the solo game or hide the last safe shared-creature projection. | + +### Safety and Control + +| ID | Requirement | +|---|---| +| SG-040 | No action can permanently harm, abandon, sell, transfer or delete the shared creature. | +| SG-041 | One guardian cannot rename, evolve or make another irreversible decision alone. | +| SG-042 | Absence and missed care cannot punish either guardian or permanently reduce the creature. | +| SG-043 | Each guardian can mute shared-creature notifications without leaving. | +| SG-044 | Each guardian can revoke their own participation. | +| SG-045 | Revocation removes access and applies the documented single-guardian fallback. | +| SG-046 | Account deletion removes unnecessary participant attribution while retaining only required lineage integrity. | + +--- + +## Experience and Interface Requirements + +### Sanctuary Presence + +- Use one clearly authored habitat or landmark for the shared creature. +- Show the same creature renderer and genetics as its canonical record. +- Use a restrained shared symbol beside the name, not a large multiplayer HUD. +- Keep normal movement and solo interactions available when synchronization is + unavailable. +- Show `Last synced recently` only when useful; avoid technical revision text. + +### Status Language + +Use plain player-facing states: + +- `Together` - current shared state is available. +- `Saving this moment` - a care action is being committed. +- `Connection paused` - the cached creature remains visible. +- `Updating your Sanctuary` - a newer revision is being fetched. +- `This action already happened` - a duplicate safely replayed. + +Do not show database, account, RPC, revision-conflict or synchronization jargon. + +### Notification Rules + +- Batch routine updates rather than notifying every care action. +- Never use guilt language such as `Your creature missed you` or `Guardian B + has not returned`. +- Allow a digest such as `Two new moments with Aster`. +- Do not expose the other guardian's online status in the MVP. + +--- + +## Canonical Data and Service Boundary + +The shared child must not be embedded as two writable copies in separate +`game_saves` rows. + +### Required Records + +- `shared_creatures`: canonical genome, lifecycle, bounded care state and + current revision. +- `shared_creature_participants`: creature, guardian, role, consent version, + notification settings and revocation state. +- `shared_creature_events`: ordered idempotent commands and before/after + revisions. +- `shared_creature_parentage`: protected parent fingerprints and source roles. + +The local save stores only the shared creature ID, last applied revision and a +non-authoritative cached projection. + +### Authority Rules + +- Direct client writes to canonical records are revoked. +- State changes run through Postgres or Edge Functions with RLS and explicit + authorization. +- Service-role credentials remain server-side. +- Three-second participant-scoped polling is the MVP synchronization transport; + the polled projection is never writable client state. +- A future Realtime Broadcast may be added only as an invalidation signal. Every + client must still refetch its authorized projection after an invalidation. + +--- + +## Privacy and Safeguarding + +Before implementation, complete and approve a fresh privacy and child-access +assessment for the intended launch countries. + +Minimum boundary: + +- connected feature restricted to eligible 16+ profiles in the MVP; +- verified durable identity for both guardians; +- no chat, public discovery, profile search, contacts or location; +- no behavioral advertising or sale of shared activity data; +- no disclosure of email, provider ID, user ID, IP address or private save; +- documented retention, deletion, revocation and dispute behavior; +- rate limits and abuse monitoring on invitations and commands; +- clear language that age self-attestation is not high-assurance verification. + +--- + +## Non-Functional Requirements + +| Area | Requirement | +|---|---| +| Consistency | No accepted test may produce divergent canonical state between two clients. | +| Availability | Temporary network failure must retain the last safe cache and resume polling without blocking solo play. | +| Latency | A committed care action should appear on the other online device within 3 seconds at p95 under normal service conditions. | +| Recovery | Reconnect must converge from any stale cached revision without manual reset. | +| Security | RLS deny tests must cover non-participants, revoked guardians and forged creature IDs. | +| Idempotency | Network retries must not duplicate care, rewards, naming or Fusion. | +| Accessibility | All shared states and actions need readable text, focus order and non-color status cues. | +| Mobile | Primary invitation, consent, reveal and care actions must fit 390x844 without overlap. | +| Observability | Record bounded operational events without creature names, messages or direct identifiers. | + +--- + +## Measurement Plan + +Measurement begins only after privacy approval and must use bounded event names. + +### Product Signals + +- eligible players who open the Shared Guardianship explanation; +- invitations created, joined, expired and cancelled; +- pairings reaching two valid consents; +- shared Fusions committed successfully; +- first shared care action completed by each guardian; +- both guardians active in the same seven-day period; +- synchronization failures and successful recoveries; +- participant revocation and single-guardian fallback completion. + +### MVP Success Thresholds for Closed Testing + +- 90% of supervised pairs understand that there is one creature visible on + both devices before consenting. +- 95% of completed Fusions reveal the same child identity on both devices. +- 100% convergence after the planned offline, retry and simultaneous-action + test matrix. +- No test produces duplicate children or irrecoverable divergent state. +- At least 80% of pairs complete one care action from each device without + coaching. +- Zero exposure of direct participant identifiers to the other guardian. + +These are validation thresholds, not launch claims. + +--- + +## Acceptance Test Matrix + +The MVP is not complete until all cases pass with two independent accounts and +two independent browser/device sessions. + +1. Create, join, consent and commit one child. +2. Verify identical child ID, genome, name state and revision on both devices. +3. Perform care on device A and observe the committed result on device B. +4. Perform care on device B while device A is offline; reconnect A and converge. +5. Submit the same command repeatedly and verify one event/reward. +6. Submit simultaneous compatible commands and verify ordered convergence. +7. Submit simultaneous conflicting commands and verify friendly rejection plus + refresh. +8. Disconnect during Fusion and resume the same reveal without a duplicate. +9. Clear one device and recover through the verified identity. +10. Attempt access from a non-participant and verify denial. +11. Revoke one guardian and verify immediate access removal plus safe continuity. +12. Delete a smoke identity and verify participant-data cleanup. +13. Interrupt projection polling and verify cached display plus reconnect recovery. +14. Confirm that portrait/video failure does not delay gameplay. +15. Confirm no chat, public discovery or personal identifier is exposed. + +--- + +## Rollout Plan + +### Phase 0: Decisions and Safety + +- Approve final feature name and player-facing promise. +- Approve durable identity method. +- Approve age boundary, privacy notice, retention and deletion policy. +- Approve single-guardian fallback and dispute behavior. + +**Stop gate:** no implementation until all four decisions have named owners. + +### Phase 1: Server Foundation + +- Add canonical tables, restrictive RLS and revoked direct writes. +- Add atomic invitation, consent, Fusion and command functions. +- Add idempotency, expected-revision and deterministic outcome tests. + +**Stop gate:** full allow/deny and concurrency suite passes before client UI. + +### Phase 2: Two-Device Internal Prototype + +- Add private invitation and consent UI. +- Add one shared-child reveal and cached device projection. +- Add one ordinary care action and contribution history. +- Add reconnect and stale-client recovery. + +**Stop gate:** complete acceptance matrix passes with synthetic accounts. + +### Phase 3: Adult-Supervised Closed Test + +- Test with a small number of known adult pairs. +- Measure comprehension, emotional response, synchronization and departure. +- Review support burden and privacy evidence. + +**Stop gate:** no child-facing or public test without explicit review. + +### Phase 4: Limited Release Decision + +- Decide whether to retain, revise or stop the feature. +- Only then consider additional care actions, shared expeditions or story use. + +--- + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| Players believe the creature is copied | Use one-child language, shared ID and identical history; test comprehension before consent. | +| One guardian dominates decisions | Require dual consent for naming and future irreversible actions. | +| Absence creates guilt | No decay, injury or punishment from inactivity; use neutral notification language. | +| State diverges across devices | Canonical server state, expected revisions, idempotency and refetch after invalidation. | +| Anonymous account is lost | Require durable verified identity before commitment. | +| Invitation reaches a stranger | Private expiring single-use invitation, rate limits and no discovery. | +| Child-safety scope expands accidentally | Preserve 16+ boundary, no communication features and formal privacy gate. | +| Feature blocks solo progression | Keep shared care optional and isolate service failures from the main game. | +| Guardian leaves or deletes account | Defined single-guardian fallback and participant-grant removal. | +| Operating costs grow | Use bounded events and compact three-second projections; evaluate private invalidations only if measured load requires them. | + +--- + +## Approved Product Decisions + +1. The player-facing name is `Shared Guardianship`. +2. The first durable identity is a verified email identity with a password. + An existing anonymous Supabase identity is converted in place so its user ID + and current save ownership do not change. Solo play never requires an + account. +3. The initial name is selected from a moderated generated list and requires + exact approval from both guardians. No free-form peer messaging is created. +4. Each player may participate in one active shared creature in the MVP. +5. If one guardian leaves, access is revoked immediately and the creature + remains safely with the continuing guardian. The departing guardian cannot + delete, transfer or rename it. +6. Contribution history uses `Guardian A` and `Guardian B`; it never displays + email addresses, account names or online presence. +7. The shared creature remains in a dedicated Sanctuary habitat in the MVP. + Story and ordinary-level integration are deferred. +8. Existing two-sibling Shared Fusion remains available only as a rollback + implementation boundary. It is not silently converted into one shared + creature. + +--- + +## Recommendation + +Proceed only after the current solo hatching, care and Fusion experiences are +consistently strong for ordinary players. Then build the smallest asynchronous +prototype: one invitation, one child, one habitat, one care action and two +devices converging on one state. + +Do not begin with synchronous gameplay or story integration. The product value +can be proven by one simple question: **Do two people feel that this is one +living creature they genuinely share, without either person's play being held +back by the other?** diff --git a/package.json b/package.json index 60daf6ee..69f6777c 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,9 @@ "gate:onboarding": "npm run gate:onboarding:static && npm run build:onboarding-ci && npm run smoke:onboarding-golden", "test:manual": "node ./scripts/serve-test-framework.js", "test:release": "npm test && npm run smoke:release", + "test:shared-guardianship": "jest --runInBand src/__tests__/DurableAccountService.test.js src/__tests__/SharedGuardianshipService.test.js src/__tests__/SharedGuardianshipServerContract.test.js src/__tests__/SharedGuardianshipGameplayContract.test.js src/__tests__/CloudSaveManager.test.js", "smoke:supabase-fusion": "node scripts/smoke-fusion-authority.js", + "smoke:shared-guardianship": "node scripts/smoke-shared-guardianship-authority.js", "smoke:living-portrait": "node scripts/smoke-living-portrait-production.js", "smoke:companion-video": "node scripts/smoke-companion-video-production.js", "generate:cinematic": "node scripts/generate-cinematic-assets.mjs", diff --git a/scripts/company/validate-adult-feedback-loop.cjs b/scripts/company/validate-adult-feedback-loop.cjs index 631c533d..f3c523ae 100644 --- a/scripts/company/validate-adult-feedback-loop.cjs +++ b/scripts/company/validate-adult-feedback-loop.cjs @@ -44,7 +44,7 @@ requireValue(!storefront.includes('Our feedback channel is being prepared now'), requireValue(netlify.includes('from = "/feedback"') && netlify.includes('from = "/feedback/"'), 'feedback redirects are missing'); const privacyText = JSON.stringify(legal.privacyPolicy); -requireValue(legal.privacyPolicy?.lastUpdated === '2026-08-27', 'privacy policy date is stale'); +requireValue(legal.privacyPolicy?.lastUpdated === '2026-09-01', 'privacy policy date is stale'); requireValue(/adult feedback pulse/i.test(privacyText), 'privacy policy does not describe adult feedback'); requireValue(/fixed-choice/i.test(privacyText) && /180 days/i.test(privacyText), 'privacy policy is missing feedback fields or retention'); requireValue(/does not ask for a name, email address, free-text answer, exact age or child details/i.test(privacyText), 'privacy policy does not state the feedback exclusion boundary'); diff --git a/scripts/smoke-shared-guardianship-authority.js b/scripts/smoke-shared-guardianship-authority.js new file mode 100644 index 00000000..da1389cf --- /dev/null +++ b/scripts/smoke-shared-guardianship-authority.js @@ -0,0 +1,563 @@ +const fs = require('fs'); +const path = require('path'); +const { randomBytes, randomUUID } = require('crypto'); + +function readEnvFile(filePath) { + if (!fs.existsSync(filePath)) return {}; + return Object.fromEntries( + fs.readFileSync(filePath, 'utf8') + .split(/\r?\n/) + .filter(line => line && !line.startsWith('#')) + .map(line => { + const separator = line.indexOf('='); + return [line.slice(0, separator), line.slice(separator + 1)]; + }) + ); +} + +function fail(message) { + throw new Error(message); +} + +function assert(condition, message) { + if (!condition) fail(message); +} + +async function describeFunctionFailure(result) { + if (!result?.error) return ''; + let detail = result.error.message || 'unknown function error'; + try { + const response = result.error.context; + const body = response?.clone + ? await response.clone().json() + : null; + if (body?.error) detail += `: ${String(body.error).slice(0, 240)}`; + } catch {} + return detail; +} + +function parentState(parentId, name, affinity, rarity, generation) { + const now = Date.now(); + return { + version: '1.1.0', + savedAt: now, + maxCreatures: 8, + creatures: [{ + id: parentId, + name, + generation, + rarity, + genes: { + id: `genes_${parentId}`, + species: 'currentNative', + rarity, + cosmicAffinity: { element: affinity }, + traits: { + bodyShape: { type: affinity === 'crystal' ? 'stocky' : 'slender' }, + colorGenome: { + primary: affinity === 'crystal' ? 0x77c9c0 : 0x7674c9, + secondary: affinity === 'crystal' ? 0xe0fff8 : 0xdedcff + } + } + }, + stats: { happiness: 90, energy: 90, health: 100 }, + mood: { current: 'steady' }, + lifecycle: { + stage: 'adult', + birthDate: now - 4 * 24 * 60 * 60 * 1000 + } + }], + breedingShrine: { + unlocked: true, + breedingHistory: [], + completedOperationIds: [], + sharedFusion: { + schemaVersion: 1, + activeInvitation: null, + completedOperationIds: [], + pendingReveal: null + } + } + }; +} + +async function run() { + const env = { + ...readEnvFile(path.join(__dirname, '..', '.env.local')), + ...process.env + }; + const serviceKey = env.SUPABASE_SECRET_KEY || env.SUPABASE_SERVICE_ROLE_KEY; + if ( + !env.VITE_SUPABASE_URL || + !env.VITE_SUPABASE_PUBLISHABLE_KEY || + !serviceKey + ) { + fail('Shared Guardianship smoke requires public and service Supabase configuration.'); + } + if (env.SHARED_GUARDIANSHIP_SMOKE_ALLOW_EPHEMERAL_USERS !== 'yes') { + fail('Set SHARED_GUARDIANSHIP_SMOKE_ALLOW_EPHEMERAL_USERS=yes to permit isolated test-user creation and cleanup.'); + } + + const { createClient } = await import('@supabase/supabase-js'); + const clientOptions = { + auth: { persistSession: false, autoRefreshToken: false } + }; + const admin = createClient(env.VITE_SUPABASE_URL, serviceKey, clientOptions); + const clients = [0, 1, 2].map(() => createClient( + env.VITE_SUPABASE_URL, + env.VITE_SUPABASE_PUBLISHABLE_KEY, + clientOptions + )); + const createdUsers = []; + const runId = `${Date.now()}-${randomBytes(4).toString('hex')}`; + const password = `${randomBytes(24).toString('base64url')}Aa7!`; + const emails = ['host', 'guest', 'outsider'].map(role => ( + `mythical-void-${role}-${runId}@example.com` + )); + const parentIds = [ + `guardianship_smoke_host_${runId}`, + `guardianship_smoke_guest_${runId}` + ]; + let invitationId = null; + let sharedCreatureId = null; + + try { + for (let index = 0; index < clients.length; index += 1) { + const created = await admin.auth.admin.createUser({ + email: emails[index], + password, + email_confirm: true, + user_metadata: { + mythical_void_test: 'shared_guardianship_authority_v1', + run_id: runId + } + }); + if (created.error || !created.data.user?.id) { + fail(`Ephemeral account creation failed: ${created.error?.message}`); + } + createdUsers.push(created.data.user.id); + const signedIn = await clients[index].auth.signInWithPassword({ + email: emails[index], + password + }); + assert( + !signedIn.error && + signedIn.data.user?.id === created.data.user.id && + signedIn.data.user?.is_anonymous !== true && + Boolean(signedIn.data.user?.email_confirmed_at), + `Verified test account sign-in failed: ${signedIn.error?.message || 'identity mismatch'}` + ); + const attested = await clients[index].rpc( + 'attest_shared_guardianship_eligibility', + { + p_age_band: 'age_18_plus', + p_terms_version: 'shared-guardianship-2026-08-31', + p_privacy_version: 'shared-guardianship-2026-08-31' + } + ); + assert( + !attested.error && attested.data?.eligible === true, + `Eligibility attestation failed: ${attested.error?.message}` + ); + } + + for (let index = 0; index < 2; index += 1) { + const saved = await clients[index].rpc('save_game_state', { + p_save_slot: 'primary', + p_save_version: '1.1.0', + p_game_state: parentState( + parentIds[index], + index === 0 ? 'Smoke Host' : 'Smoke Guest', + index === 0 ? 'void' : 'crystal', + index === 0 ? 'rare' : 'epic', + index + 1 + ), + p_client_saved_at: new Date().toISOString(), + p_expected_revision: 0 + }); + assert( + !saved.error && saved.data?.revision === 1, + `Smoke save failed: ${saved.error?.message}` + ); + } + + const createKey = `invite_${randomUUID().replace(/-/g, '')}`; + const created = await clients[0].rpc( + 'create_shared_guardianship_invitation', + { + p_parent_id: parentIds[0], + p_expected_revision: 1, + p_idempotency_key: createKey + } + ); + assert( + !created.error && + created.data?.invitationId && + /^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(created.data?.code || ''), + `Invitation creation failed: ${created.error?.message}` + ); + invitationId = created.data.invitationId; + const createReplay = await clients[0].rpc( + 'create_shared_guardianship_invitation', + { + p_parent_id: parentIds[0], + p_expected_revision: 1, + p_idempotency_key: createKey + } + ); + assert( + !createReplay.error && + createReplay.data?.invitationId === invitationId && + createReplay.data?.code === created.data.code && + createReplay.data?.replay === true, + `Invitation retry did not return the original private code: ${createReplay.error?.message}` + ); + + const directRead = await clients[0] + .from('shared_guardianship_invitations') + .select('invitation_id') + .eq('invitation_id', invitationId); + assert(Boolean(directRead.error), 'Direct invitation table access was unexpectedly allowed.'); + + const joined = await clients[1].rpc( + 'join_shared_guardianship_invitation', + { + p_code: created.data.code, + p_parent_id: parentIds[1], + p_expected_revision: 1 + } + ); + assert( + !joined.error && + joined.data?.status === 'paired' && + joined.data?.peerSignal?.rarity === 'rare' && + !JSON.stringify(joined.data).includes('Smoke Host') && + !JSON.stringify(joined.data).includes(parentIds[0]), + `Invitation join or privacy projection failed: ${joined.error?.message}` + ); + const joinReplay = await clients[1].rpc( + 'join_shared_guardianship_invitation', + { + p_code: created.data.code, + p_parent_id: parentIds[1], + p_expected_revision: 1 + } + ); + assert( + !joinReplay.error && + joinReplay.data?.invitationId === invitationId && + joinReplay.data?.replay === true, + `Invitation join retry was not idempotent: ${joinReplay.error?.message}` + ); + + const hostConfirmed = await clients[0].rpc( + 'confirm_shared_guardianship_invitation', + { p_invitation_id: invitationId } + ); + assert( + !hostConfirmed.error && + hostConfirmed.data?.status === 'paired' && + hostConfirmed.data?.hostConfirmed === true, + `Host confirmation failed: ${hostConfirmed.error?.message}` + ); + const guestConfirmed = await clients[1].rpc( + 'confirm_shared_guardianship_invitation', + { p_invitation_id: invitationId } + ); + assert( + !guestConfirmed.error && + guestConfirmed.data?.status === 'ready' && + /^fusion_guardianship_/.test(guestConfirmed.data?.operationId || '') && + Boolean(guestConfirmed.data?.sharedCreatureId), + `Guest confirmation failed: ${guestConfirmed.error?.message}` + ); + sharedCreatureId = guestConfirmed.data.sharedCreatureId; + + const hostExecution = await clients[0].functions.invoke('execute-fusion', { + body: { guardianshipInvitationId: invitationId } + }); + const guestExecution = await clients[1].functions.invoke('execute-fusion', { + body: { guardianshipInvitationId: invitationId } + }); + const executionFailure = await describeFunctionFailure( + hostExecution.error ? hostExecution : guestExecution + ); + assert( + !hostExecution.error && + !guestExecution.error && + hostExecution.data?.status === 'staged' && + guestExecution.data?.status === 'staged' && + hostExecution.data?.offspring?.offspringData?.creatureId === + guestExecution.data?.offspring?.offspringData?.creatureId && + JSON.stringify(hostExecution.data?.offspring?.offspringGenes) === + JSON.stringify(guestExecution.data?.offspring?.offspringGenes) && + guestExecution.data?.replay === true, + `Canonical execution or replay failed: ${executionFailure}` + ); + + const hostNamed = await clients[0].rpc('submit_shared_guardianship_name', { + p_invitation_id: invitationId, + p_name: 'Aster' + }); + assert( + !hostNamed.error && hostNamed.data?.awaitingAgreement === true, + `Host naming failed: ${hostNamed.error?.message}` + ); + const guestNamed = await clients[1].rpc('submit_shared_guardianship_name', { + p_invitation_id: invitationId, + p_name: 'Aster' + }); + assert( + !guestNamed.error && + guestNamed.data?.status === 'committed' && + guestNamed.data?.nameAgreed === true, + `Shared name agreement failed: ${guestNamed.error?.message}` + ); + + const listed = await Promise.all([0, 1].map(index => ( + clients[index].rpc('list_shared_guardianship_creatures') + ))); + assert( + listed.every(result => !result.error && result.data?.length === 1) && + listed[0].data[0].sharedCreatureId === sharedCreatureId && + listed[1].data[0].sharedCreatureId === sharedCreatureId && + listed[0].data[0].runtimeId === listed[1].data[0].runtimeId && + JSON.stringify(listed[0].data[0].genes) === + JSON.stringify(listed[1].data[0].genes) && + listed[0].data[0].revision === 1 && + listed[1].data[0].revision === 1, + 'Both guardians did not receive the same canonical child projection.' + ); + + const outsiderRead = await clients[2].rpc( + 'get_shared_guardianship_projection', + { p_creature_id: sharedCreatureId } + ); + assert( + Boolean(outsiderRead.error) && + outsiderRead.error.message.includes('shared_guardianship_access_denied'), + 'A nonparticipant could read the shared creature.' + ); + + for (let attempt = 0; attempt < 8; attempt += 1) { + const guessed = await clients[2].rpc( + 'join_shared_guardianship_invitation', + { + p_code: randomBytes(6).toString('hex').toUpperCase(), + p_parent_id: 'missing-parent', + p_expected_revision: 1 + } + ); + assert( + !guessed.error && + guessed.data?.errorCode === 'shared_guardianship_invitation_unavailable', + `Private-code attempt ${attempt + 1} did not fail safely.` + ); + } + const rateLimited = await clients[2].rpc( + 'join_shared_guardianship_invitation', + { + p_code: randomBytes(6).toString('hex').toUpperCase(), + p_parent_id: 'missing-parent', + p_expected_revision: 1 + } + ); + assert( + !rateLimited.error && + rateLimited.data?.errorCode === 'shared_guardianship_join_rate_limited', + 'Private-code guessing attempts were not durably rate limited.' + ); + + const careKey = `care_tend_${randomUUID().replace(/-/g, '')}`; + const hostCare = await clients[0].rpc('perform_shared_guardianship_care', { + p_creature_id: sharedCreatureId, + p_action: 'tend', + p_idempotency_key: careKey, + p_expected_revision: 1 + }); + const hostReplay = await clients[0].rpc('perform_shared_guardianship_care', { + p_creature_id: sharedCreatureId, + p_action: 'tend', + p_idempotency_key: careKey, + p_expected_revision: 1 + }); + assert( + !hostCare.error && + !hostReplay.error && + hostCare.data?.revision === 2 && + hostReplay.data?.revision === 2 && + hostReplay.data?.replay === true, + `Idempotent care failed: ${hostCare.error?.message || hostReplay.error?.message}` + ); + + const concurrentCareKeys = [ + `care_play_${randomUUID().replace(/-/g, '')}`, + `care_rest_${randomUUID().replace(/-/g, '')}` + ]; + const concurrentCare = await Promise.all([ + clients[0].rpc('perform_shared_guardianship_care', { + p_creature_id: sharedCreatureId, + p_action: 'play', + p_idempotency_key: concurrentCareKeys[0], + p_expected_revision: 2 + }), + clients[1].rpc('perform_shared_guardianship_care', { + p_creature_id: sharedCreatureId, + p_action: 'rest', + p_idempotency_key: concurrentCareKeys[1], + p_expected_revision: 2 + }) + ]); + assert( + concurrentCare.every(result => !result.error) && + concurrentCare.some(result => result.data?.rebased === true) && + Math.max(...concurrentCare.map(result => result.data?.revision || 0)) === 4, + `Compatible simultaneous care did not serialize safely: ${concurrentCare.map(result => result.error?.message).filter(Boolean).join('; ')}` + ); + const staleCare = await clients[1].rpc('perform_shared_guardianship_care', { + p_creature_id: sharedCreatureId, + p_action: 'play', + p_idempotency_key: `care_play_${randomUUID().replace(/-/g, '')}`, + p_expected_revision: 1 + }); + assert( + !staleCare.error && + staleCare.data?.conflict === true && + staleCare.data?.revision === 4, + `A stale client did not receive the newest projection: ${staleCare.error?.message}` + ); + + await clients[0].auth.signOut({ scope: 'local' }); + const reconnectedHost = createClient( + env.VITE_SUPABASE_URL, + env.VITE_SUPABASE_PUBLISHABLE_KEY, + clientOptions + ); + const reconnected = await reconnectedHost.auth.signInWithPassword({ + email: emails[0], + password + }); + assert(!reconnected.error, `Host reconnect failed: ${reconnected.error?.message}`); + const converged = await reconnectedHost.rpc( + 'get_shared_guardianship_projection', + { p_creature_id: sharedCreatureId } + ); + assert( + !converged.error && + converged.data?.revision === 4 && + converged.data?.care?.curiosity === staleCare.data?.care?.curiosity, + `Reconnect did not converge: ${converged.error?.message}` + ); + + const guestMuted = await clients[1].rpc( + 'set_shared_guardianship_notifications', + { + p_creature_id: sharedCreatureId, + p_muted: true, + p_idempotency_key: `notice_${randomUUID().replace(/-/g, '')}`, + p_expected_revision: 4 + } + ); + const hostUnmuted = await reconnectedHost.rpc( + 'get_shared_guardianship_projection', + { p_creature_id: sharedCreatureId } + ); + assert( + !guestMuted.error && + guestMuted.data?.notificationsMuted === true && + guestMuted.data?.revision === 5 && + hostUnmuted.data?.notificationsMuted === false, + 'Participant-scoped notification preference failed.' + ); + + const hostLeaveKey = `leave_${randomUUID().replace(/-/g, '')}`; + const hostLeft = await reconnectedHost.rpc('leave_shared_guardianship', { + p_creature_id: sharedCreatureId, + p_idempotency_key: hostLeaveKey, + p_expected_revision: 5 + }); + const hostLeaveReplay = await reconnectedHost.rpc('leave_shared_guardianship', { + p_creature_id: sharedCreatureId, + p_idempotency_key: hostLeaveKey, + p_expected_revision: 5 + }); + const revokedRead = await reconnectedHost.rpc( + 'get_shared_guardianship_projection', + { p_creature_id: sharedCreatureId } + ); + const survivorRead = await clients[1].rpc( + 'get_shared_guardianship_projection', + { p_creature_id: sharedCreatureId } + ); + assert( + !hostLeft.error && + hostLeft.data?.left === true && + hostLeft.data?.remainingGuardians === 1 && + hostLeaveReplay.data?.replay === true && + Boolean(revokedRead.error) && + !survivorRead.error && + survivorRead.data?.status === 'active' && + survivorRead.data?.guardianCount === 1, + 'Departure did not revoke only the departing guardian.' + ); + const finalLeaveKey = `leave_${randomUUID().replace(/-/g, '')}`; + const lastGuardianLeave = await clients[1].rpc( + 'leave_shared_guardianship', + { + p_creature_id: sharedCreatureId, + p_idempotency_key: finalLeaveKey, + p_expected_revision: survivorRead.data?.revision + } + ); + assert( + !lastGuardianLeave.error && + lastGuardianLeave.data?.left === true && + lastGuardianLeave.data?.remainingGuardians === 0 && + lastGuardianLeave.data?.archived === true, + `The final guardian could not leave safely: ${lastGuardianLeave.error?.message}` + ); + + console.log(JSON.stringify({ + verifiedPermanentAccounts: true, + ageAndPolicyGate: true, + hashedExpiringCode: true, + invitationCreateReplay: true, + invitationJoinReplay: true, + dualGuardianConsent: true, + oneCanonicalChild: true, + participantScopedProjection: true, + nonparticipantDenied: true, + durableJoinRateLimit: true, + idempotentCare: true, + compatibleConcurrentCare: true, + staleWriteReturnedLatestProjection: true, + bidirectionalConvergence: true, + reconnectConvergence: true, + privateNotificationPreference: true, + departureRevokesOnlyLeaver: true, + departureReplaySafe: true, + finalGuardianArchivesSafely: true + })); + } finally { + if (sharedCreatureId) { + const removedCreature = await admin + .from('shared_guardianship_creatures') + .delete() + .eq('creature_id', sharedCreatureId); + if (removedCreature.error) { + console.error('[shared-guardianship-smoke] Creature cleanup failed.'); + } + } + for (const userId of createdUsers.reverse()) { + const removed = await admin.auth.admin.deleteUser(userId); + if (removed.error) { + console.error('[shared-guardianship-smoke] Test identity cleanup failed.'); + } + } + } +} + +run().catch(error => { + console.error(error.message); + process.exit(1); +}); diff --git a/src/__tests__/CloudSaveManager.test.js b/src/__tests__/CloudSaveManager.test.js index 35732f81..f276da59 100644 --- a/src/__tests__/CloudSaveManager.test.js +++ b/src/__tests__/CloudSaveManager.test.js @@ -851,4 +851,30 @@ describe('CloudSaveManager', () => { expect(calls.rpcs).toHaveLength(0); jest.useRealTimers(); }); + + test('adopting a durable account replaces the cached anonymous identity and restores its remote save', async () => { + const storage = createStorage(); + storage.setItem('mythical_void_cloud_save_enabled', 'true'); + const remoteSave = { + revision: 4, + game_state: { version: '1.1.0', savedAt: 5000, creature: { name: 'Remote' } }, + client_saved_at: '2026-08-31T12:00:00Z' + }; + const { client, calls } = createClient({ remoteSave }); + const gameState = createGameState(); + const manager = new CloudSaveManager({ client, gameState, storage }); + manager.currentUser = { id: 'anonymous-user', is_anonymous: true }; + + await manager.adoptAuthenticatedSession({ + id: 'durable-user', is_anonymous: false + }, { preferRemote: true }); + + expect(manager.currentUser.id).toBe('durable-user'); + expect(manager.remoteRevision).toBe(4); + expect(gameState.applyExternalSave).toHaveBeenCalledWith( + remoteSave.game_state, + { source: 'cloud', persist: true } + ); + expect(calls.rpcs).toHaveLength(0); + }); }); diff --git a/src/__tests__/CloudSavePrivacyContract.test.js b/src/__tests__/CloudSavePrivacyContract.test.js index 4f332684..5b22fddd 100644 --- a/src/__tests__/CloudSavePrivacyContract.test.js +++ b/src/__tests__/CloudSavePrivacyContract.test.js @@ -119,7 +119,7 @@ describe('Cloud Save privacy contract', () => { ); }); - test('deletes anonymous identities only through an authenticated server function', () => { + test('deletes identities only through an authenticated server function', () => { const functionSource = fs.readFileSync( path.join( __dirname, @@ -129,7 +129,12 @@ describe('Cloud Save privacy contract', () => { ); expect(functionSource).toContain('callerClient.auth.getUser()'); - expect(functionSource).toContain('if (!user.is_anonymous)'); + expect(functionSource).toContain( + "body.confirmation !== 'DELETE_PERMANENT_ACCOUNT'" + ); + expect(functionSource).toContain( + 'Date.now() - lastSignInAt <= 5 * 60 * 1000' + ); expect(functionSource).toContain('removeAllMediaFiles'); expect(functionSource).toContain('removalBatchSize = 100'); expect(functionSource).toContain("'creature-portraits'"); diff --git a/src/__tests__/DurableAccountService.test.js b/src/__tests__/DurableAccountService.test.js new file mode 100644 index 00000000..aef375b9 --- /dev/null +++ b/src/__tests__/DurableAccountService.test.js @@ -0,0 +1,207 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function loadModule() { + const filePath = path.join(__dirname, '../systems/DurableAccountService.js'); + const source = fs.readFileSync(filePath, 'utf8') + .replace('export class DurableAccountError', 'class DurableAccountError') + .replace('export class DurableAccountService', 'class DurableAccountService') + .replace(/if \(typeof window !== 'undefined'\) \{[\s\S]*$/, '') + .concat('\nmodule.exports = { DurableAccountService, DurableAccountError };'); + const sandbox = { + module: { exports: {} }, + exports: {}, + console, + URL, + Promise, + String, + Error, + window: { location: { origin: 'https://mythicalvoid.com' } } + }; + vm.runInNewContext(source, sandbox, { filename: filePath }); + return sandbox.module.exports; +} + +const { DurableAccountService } = loadModule(); + +function harness(options = {}) { + const sessionUser = options.sessionUser || { + id: 'verified-user', + email: 'guardian@example.test', + email_confirmed_at: '2026-08-31T12:00:00Z', + is_anonymous: false, + user_metadata: { mythical_void_password_ready: true } + }; + const auth = { + getSession: jest.fn(async () => ({ + data: { session: { user: sessionUser } }, + error: null + })), + getUser: jest.fn(async () => ({ data: { user: sessionUser }, error: null })), + updateUser: jest.fn(async payload => ({ data: { user: sessionUser }, error: null, payload })), + verifyOtp: jest.fn(async () => ({ data: {}, error: null })), + signInWithPassword: jest.fn(async () => ({ data: { user: sessionUser }, error: null })), + signOut: jest.fn(async () => ({ error: null })), + resetPasswordForEmail: jest.fn(async () => ({ data: {}, error: null })) + }; + const functions = { + invoke: jest.fn(async () => ({ data: { deleted: true }, error: null })) + }; + const gameState = { set: jest.fn() }; + const cloudSave = { + client: { auth, functions }, + gameState, + currentUser: sessionUser, + ensureSession: jest.fn(async () => ({ id: 'anonymous-user', is_anonymous: true })), + adoptAuthenticatedSession: jest.fn(async () => ({ status: 'synced' })), + synchronize: jest.fn(async () => ({ status: 'synced' })), + disable: jest.fn() + }; + return { + auth, + cloudSave, + functions, + service: new DurableAccountService({ client: { auth, functions }, cloudSave }) + }; +} + +describe('DurableAccountService', () => { + test('requires a verified non-anonymous email identity', async () => { + const { service } = harness(); + await expect(service.getStatus({ refresh: true })).resolves.toEqual( + expect.objectContaining({ permanent: true, verified: true, anonymous: false }) + ); + + const anonymous = harness({ sessionUser: { + id: 'anonymous-user', is_anonymous: true, email: null, email_confirmed_at: null + }}); + await expect(anonymous.service.getStatus({ refresh: true })).resolves.toEqual( + expect.objectContaining({ permanent: false, anonymous: true }) + ); + }); + + test('converts the existing anonymous identity instead of creating a second user', async () => { + const { service, auth, cloudSave } = harness({ sessionUser: { + id: 'anonymous-user', is_anonymous: true, email: null, email_confirmed_at: null + }}); + await service.beginUpgrade('Guardian@Example.test'); + + expect(cloudSave.ensureSession).toHaveBeenCalledTimes(1); + expect(auth.updateUser).toHaveBeenCalledWith( + { email: 'guardian@example.test' }, + { + emailRedirectTo: + 'https://mythicalvoid.com/play/?sharedGuardianshipAccount=1' + } + ); + expect(auth.signInWithPassword).not.toHaveBeenCalled(); + }); + + test('verified upgrade keeps the same cloud identity and does not prefer an older remote save', async () => { + const { service, auth, cloudSave } = harness(); + await service.finishUpgrade('a-long-safe-password'); + + expect(auth.updateUser).toHaveBeenCalledWith({ + password: 'a-long-safe-password', + data: { mythical_void_password_ready: true } + }); + expect(cloudSave.adoptAuthenticatedSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'verified-user' }), + { preferRemote: false } + ); + }); + + test('sign-in adopts the durable account and prefers its canonical remote save', async () => { + const { service, cloudSave, auth } = harness(); + await service.signIn('guardian@example.test', 'a-long-safe-password'); + expect(auth.updateUser).toHaveBeenCalledWith({ + data: { mythical_void_password_ready: true } + }); + expect(cloudSave.adoptAuthenticatedSession).toHaveBeenCalledWith( + expect.objectContaining({ id: 'verified-user' }), + { preferRemote: true } + ); + }); + + test('does not treat verified email without a recovery password as complete', async () => { + const { service } = harness({ sessionUser: { + id: 'verified-user', + email: 'guardian@example.test', + email_confirmed_at: '2026-08-31T12:00:00Z', + is_anonymous: false, + user_metadata: {} + }}); + await expect(service.getStatus({ refresh: true })).resolves.toEqual( + expect.objectContaining({ + permanent: false, + identityVerified: true, + passwordReady: false + }) + ); + await expect(service.beginUpgrade('guardian@example.test')).resolves.toEqual({ + emailSent: false, + alreadyPermanent: false, + passwordRequired: true + }); + }); + + test('password reset returns to the dedicated recovery flow without revealing account existence', async () => { + const { service, auth } = harness(); + await service.requestPasswordReset('guardian@example.test'); + expect(auth.resetPasswordForEmail).toHaveBeenCalledWith( + 'guardian@example.test', + { redirectTo: 'https://mythicalvoid.com/play/?accountRecovery=1' } + ); + }); + + test('sign-out removes shared projections from this device and leaves solo local play intact', async () => { + const { service, auth, cloudSave } = harness(); + await service.signOut(); + expect(auth.signOut).toHaveBeenCalledWith({ scope: 'local' }); + expect(cloudSave.gameState.set).toHaveBeenCalledWith( + 'sharedGuardianship.projections', + [] + ); + expect(cloudSave.disable).toHaveBeenCalledTimes(1); + expect(cloudSave.currentUser).toBeNull(); + }); + + test('permanent account deletion requires fresh password proof and explicit words', async () => { + const { service, auth, functions, cloudSave } = harness(); + + await service.deleteAccount('a-long-safe-password', 'DELETE'); + + expect(auth.signInWithPassword).toHaveBeenCalledWith({ + email: 'guardian@example.test', + password: 'a-long-safe-password' + }); + expect(functions.invoke).toHaveBeenCalledWith( + 'delete-cloud-identity', + { body: { confirmation: 'DELETE_PERMANENT_ACCOUNT' } } + ); + expect(cloudSave.gameState.set).toHaveBeenCalledWith( + 'sharedGuardianship.projections', + [] + ); + expect(cloudSave.disable).toHaveBeenCalledTimes(1); + }); + + test('does not contact auth when account deletion confirmation is incomplete', async () => { + const { service, auth, functions } = harness(); + + await expect(service.deleteAccount('a-long-safe-password', 'delete')) + .rejects.toMatchObject({ + code: 'account_deletion_confirmation_required' + }); + expect(auth.signInWithPassword).not.toHaveBeenCalled(); + expect(functions.invoke).not.toHaveBeenCalled(); + }); + + test('rejects invalid email and short passwords before an auth request', async () => { + const { service, auth } = harness(); + await expect(service.beginUpgrade('not-email')).rejects.toMatchObject({ code: 'email_invalid' }); + await expect(service.signIn('guardian@example.test', 'short')).rejects.toMatchObject({ code: 'invalid_login' }); + expect(auth.signInWithPassword).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/SharedFusionGameplayContract.test.js b/src/__tests__/SharedFusionGameplayContract.test.js index adf94c4f..54e9cfb0 100644 --- a/src/__tests__/SharedFusionGameplayContract.test.js +++ b/src/__tests__/SharedFusionGameplayContract.test.js @@ -50,9 +50,9 @@ describe('Shared Fusion gameplay contract', () => { expect(pod).toContain( 'this.createSharedFusionButton()' ); - expect(pod).toContain( - 'const sharedLinkLane = this.isSharedFusionAvailable() ? 65 : 0' - ); + expect(pod).toContain('this.isSharedFusionAvailable() ||'); + expect(pod).toContain('this.isSharedGuardianshipAvailable()'); + expect(pod).toContain(') ? 65 : 0'); expect(pod).toContain( 'const actionX = this.layout.action.x + sharedLinkLane' ); diff --git a/src/__tests__/SharedGuardianshipGameplayContract.test.js b/src/__tests__/SharedGuardianshipGameplayContract.test.js new file mode 100644 index 00000000..ea438762 --- /dev/null +++ b/src/__tests__/SharedGuardianshipGameplayContract.test.js @@ -0,0 +1,106 @@ +const fs = require('fs'); +const path = require('path'); + +function read(relativePath) { + return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8'); +} + +describe('Shared Guardianship gameplay contract', () => { + const config = JSON.parse(read('config/sharedGuardianship.json')); + const pod = read('scenes/FusionPodScene.js'); + const service = read('systems/SharedGuardianshipService.js'); + const sanctuary = read('scenes/GameScene.js'); + const modal = read('ui/SharedGuardianshipModal.js'); + const care = read('ui/SharedCreatureCareModal.js'); + const css = read('styles/main.css'); + const legal = read('site/storefront.js'); + const inGameLegal = JSON.parse(read('config/legal.json')); + const game = read('game.js'); + + test('keeps story, levels, chat and public discovery outside MVP', () => { + expect(config).toEqual(expect.objectContaining({ + storyIntegration: false, + levelIntegration: false, + chat: false, + publicDiscovery: false, + onlinePresence: false, + freeformPeerText: false + })); + }); + + test('offers the protected flow through Fusion while preserving legacy Fusion', () => { + expect(pod).toContain('isSharedGuardianshipAvailable'); + expect(pod).toContain('window.SharedGuardianship?.isEnabled?.()'); + expect(pod).toContain('getSharedGuardianshipEntryAvailability'); + expect(pod).toContain('new SharedGuardianshipModal(this, {'); + expect(pod).toContain("sharedGuardianshipAvailable ? 'SHARE' : 'LINK'"); + expect(pod).toContain('this.isSharedFusionAvailable()'); + }); + + test('explains one creature, two devices, eligibility and exact consent', () => { + expect(modal).toContain('One creature. Two Sanctuaries.'); + expect(modal).toContain('ENABLE PRIVATE SAVE & CONTINUE'); + expect(modal).toContain('this.cloudSave.enable({'); + expect(modal).toContain('I am 16 or older'); + expect(modal).toContain('I understand this creates one shared creature'); + expect(modal).toContain("privacyLink.href = '/privacy/'"); + expect(modal).toContain("termsLink.href = '/terms/'"); + expect(modal).not.toContain('messageInput'); + }); + + test('shows one canonical creature in Sanctuary with connection-safe care', () => { + expect(sanctuary).toContain('initializeSharedGuardianshipHabitat'); + expect(sanctuary).toContain('refreshAll()'); + expect(sanctuary).toContain('SHARED HABITAT // TAP TO CARE'); + expect(sanctuary).toContain('createRandomizedSpaceMythicCreature'); + expect(care).toContain('This is the same creature in both Sanctuaries.'); + expect(care).toContain('Mute optional shared activity notices'); + expect(care).toContain('The other guardian keeps the creature.'); + expect(care).toContain('With no guardian remaining'); + expect(care).toContain('this.projection.guardianCount > 1'); + expect(care).toContain('SIGN OUT OF THIS DEVICE'); + expect(care).toContain('this.service.account.signOut()'); + expect(care).toContain('Delete account and shared access'); + expect(care).toContain('DELETE ACCOUNT PERMANENTLY'); + expect(care).toContain('this.service.account.deleteAccount('); + expect(service).toContain('if (stopped || inFlight) return;'); + expect(service).toContain('if (!stopped) callback(null, error);'); + expect(modal).toContain('this.busy || this.pollInFlight'); + expect(modal).toContain('generation !== this.pollGeneration'); + }); + + test('has a bounded mobile layout and durable account recovery surface', () => { + expect(css).toContain('@media (max-width: 520px)'); + expect(css).toContain('.durable-account-recovery'); + expect(css).toContain('min-height: 44px'); + expect(css).toContain('.shared-guardianship-modal button:focus-visible'); + expect(modal).toContain("event.key !== 'Tab'"); + expect(modal).toContain('this.previousFocus.focus'); + expect(care).toContain("event.key !== 'Tab'"); + expect(care).toContain("setAttribute('aria-live', 'polite')"); + expect(game).toContain("testFusion === 'guardianship'"); + expect(pod).toContain('previewSharedGuardianshipAccount'); + expect(pod).toContain('previewAccess: true'); + expect(game).toContain("urlParams.get('testSharedGuardianship')"); + expect(game).toContain("['care', 'access', 'delete']"); + }); + + test('public privacy and terms explain the connected feature plainly', () => { + expect(legal).toContain('

Shared Guardianship

'); + expect(legal).toContain('requires a verified email account'); + expect(legal).toContain('permanently delete their account'); + expect(legal).toContain('confirming the current password'); + expect(legal).toContain('has no chat, public profile, player search'); + expect(legal).toContain('A guardian may leave'); + const privacy = inGameLegal.privacyPolicy.sections.find( + section => section.heading === 'Shared Guardianship' + ); + const terms = inGameLegal.termsOfService.sections.find( + section => section.heading === 'Shared Guardianship' + ); + expect(privacy.content).toContain('verified email account and password'); + expect(privacy.content).toContain('cannot see your email'); + expect(terms.content).toContain('optional 16+ feature'); + expect(terms.content).toContain('cannot remove the other guardian'); + }); +}); diff --git a/src/__tests__/SharedGuardianshipServerContract.test.js b/src/__tests__/SharedGuardianshipServerContract.test.js new file mode 100644 index 00000000..127fe80b --- /dev/null +++ b/src/__tests__/SharedGuardianshipServerContract.test.js @@ -0,0 +1,113 @@ +const fs = require('fs'); +const path = require('path'); + +const migration = [ + '20260831000200_create_shared_guardianship.sql', + '20260831000300_rate_limit_shared_guardianship.sql', + '20260901000100_require_guardianship_password_identity.sql', + '20260901000200_fix_guardianship_child_reservation.sql', + '20260901000300_secure_guardianship_reservation_triggers.sql' +].map(file => fs.readFileSync(path.join( + __dirname, + `../../supabase/migrations/${file}` +), 'utf8')).join('\n'); +const edgeFunction = fs.readFileSync(path.join( + __dirname, + '../../supabase/functions/execute-fusion/index.ts' +), 'utf8'); +const governance = fs.readFileSync(path.join( + __dirname, + '../../docs/planning/SHARED_GUARDIANSHIP_GOVERNANCE.md' +), 'utf8'); + +describe('Shared Guardianship server and governance contract', () => { + test('keeps canonical tables private and exposes bounded authenticated commands', () => { + [ + 'shared_guardianship_profiles', + 'shared_guardianship_invitations', + 'shared_guardianship_creatures', + 'shared_guardianship_participants', + 'shared_guardianship_parentage', + 'shared_guardianship_events', + 'shared_guardianship_commands' + ].forEach(table => { + expect(migration).toContain(`alter table public.${table} force row level security`); + expect(migration).toContain(`revoke all on table public.${table} from anon, authenticated`); + }); + expect(migration).toContain('grant execute on function public.perform_shared_guardianship_care(uuid,text,text,bigint) to authenticated'); + expect(migration).not.toContain('grant select on table public.shared_guardianship'); + }); + + test('enforces durable identity, current 16+ consent and no self pairing', () => { + expect(migration).toContain('account.is_anonymous is false'); + expect(migration).toContain("coalesce(account.encrypted_password, '') <> ''"); + expect(migration).toContain('Client metadata is intentionally not trusted'); + expect(migration).toContain("profile.age_band in ('age_16_17', 'age_18_plus')"); + expect(migration).toContain("profile.terms_version = 'shared-guardianship-2026-08-31'"); + expect(migration).toContain('check (guest_user_id is null or guest_user_id <> host_user_id)'); + }); + + test('enforces one active shared creature and one canonical child per invitation', () => { + expect(migration).toContain('shared_guardianship_one_active_per_user_idx'); + expect(migration).toContain("where status = 'active'"); + expect(migration).toContain('source_invitation_id uuid unique'); + expect(migration).toContain('child_id uuid unique'); + expect(migration).toContain("jsonb_array_length(coalesce(p_result->'offspring','[]'::jsonb)) <> 1"); + }); + + test('allows a pending child reservation but enforces committed-child integrity', () => { + expect(migration).toContain('drop constraint if exists shared_guardianship_invitation_child_fk'); + expect(migration).toContain('assert_committed_guardianship_child_exists'); + expect(migration).toContain("new.status = 'committed'"); + expect(migration).toContain('shared_guardianship_committed_child_missing'); + expect(migration).toContain('clear_guardianship_child_reservation_on_delete'); + expect(migration).toContain('alter function public.assert_committed_guardianship_child_exists()'); + expect(migration).toContain('alter function public.clear_guardianship_child_reservation_on_delete()'); + expect(migration).toContain('security definer;'); + }); + + test('uses revision checks and idempotency for shared care', () => { + expect(migration).toContain('unique (creature_id, idempotency_key)'); + expect(migration).toContain('p_expected_revision < v_creature.revision - 1'); + expect(migration).toContain("jsonb_build_object('conflict',true,'replay',false,'rebased',false)"); + expect(migration).toContain("jsonb_build_object('replay',true,'rebased',false)"); + expect(migration).toContain("v_rebased := p_expected_revision = v_creature.revision - 1"); + expect(migration).toContain("command_kind in ('care', 'notifications', 'leave')"); + }); + + test('bounds abuse records, history and departing-guardian attribution', () => { + expect(migration).toContain("interval '24 hours'"); + expect(migration).toContain("interval '30 days'"); + expect(migration).toContain('offset 100'); + expect(migration).toContain('update public.shared_guardianship_events set actor_user_id = null'); + expect(migration).toContain('archive_orphaned_shared_guardianship_after_participant_change'); + expect(migration).toContain('purge_shared_guardianship_retention'); + expect(migration).toContain("status = case when v_remaining = 0 then 'archived' else status end"); + expect(migration).toContain('enforce_shared_guardianship_invitation_rate_before_insert'); + expect(migration).toContain("command_kind = 'care' then 30 else 10"); + expect(migration).toContain("new.command_kind = 'leave'"); + }); + + test('persists guessing attempts and makes invitation creation and joining retryable', () => { + expect(migration).toContain('create_idempotency_key text not null'); + expect(migration).toContain('shared_guardianship_invite_create_replay_idx'); + expect(migration).toContain("'errorCode', 'shared_guardianship_invitation_unavailable'"); + expect(migration).toContain("v_invitation.guest_user_id = v_user_id"); + expect(migration).toContain("jsonb_build_object('replay', true)"); + }); + + test('keeps service-role generation behind a participant-scoped invitation', () => { + expect(edgeFunction).toContain('resolve_shared_guardianship_execution'); + expect(edgeFunction).toContain('get_shared_guardianship_execution_context'); + expect(edgeFunction).toContain('stage_shared_guardianship_result'); + expect(edgeFunction).toContain('sharedGuardianshipParticipantResponse'); + expect(edgeFunction).toContain('Choose one protected Fusion mode'); + }); + + test('records the safety boundary and required release evidence', () => { + expect(governance).toContain('No absence, missed action or departure can injure or delete the creature.'); + expect(governance).toContain('No test account credentials, email addresses or invitation codes are committed'); + expect(governance).toContain('two permanent-account invitation, consent and one-child proof'); + expect(governance).toContain('manual-linking and production email delivery smoke'); + }); +}); diff --git a/src/__tests__/SharedGuardianshipService.test.js b/src/__tests__/SharedGuardianshipService.test.js new file mode 100644 index 00000000..c607c49d --- /dev/null +++ b/src/__tests__/SharedGuardianshipService.test.js @@ -0,0 +1,248 @@ +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const invitationId = '824363b2-d374-4b44-bf7f-1d7a177fa074'; +const creatureId = '0ba73666-fc6f-4af2-9138-12cc47c034ae'; + +function loadModule() { + const filePath = path.join(__dirname, '../systems/SharedGuardianshipService.js'); + const contract = JSON.parse(fs.readFileSync( + path.join(__dirname, '../config/sharedGuardianship.json'), + 'utf8' + )); + contract.enabled = true; + const source = fs.readFileSync(filePath, 'utf8') + .replace("import contract from '../config/sharedGuardianship.json';", `const contract = ${JSON.stringify(contract)};`) + .replace('export class SharedGuardianshipError', 'class SharedGuardianshipError') + .replace('export function getSharedGuardianshipAvailability', 'function getSharedGuardianshipAvailability') + .replace('export function getSharedGuardianshipEntryAvailability', 'function getSharedGuardianshipEntryAvailability') + .replace('export class SharedGuardianshipService', 'class SharedGuardianshipService') + .replace(/if \(typeof window !== 'undefined'\) \{[\s\S]*$/, '') + .concat('\nmodule.exports = { SharedGuardianshipService, SharedGuardianshipError, normalizeCode, normalizeInvitation, normalizeProjection, getSharedGuardianshipAvailability, getSharedGuardianshipEntryAvailability, isSharedGuardianshipEnabled, contract };'); + const sandbox = { + module: { exports: {} }, exports: {}, console, Date, Promise, Map, Set, + Object, Array, Number, String, Math, JSON, Error, Uint8Array, + crypto: { + randomUUID: () => 'f7d62d73-663f-4f31-95e6-d0175eb00b2a' + }, + window: { + location: { hostname: 'mythicalvoid.com', search: '' }, + FusionConsent: { + getFusionCompanionReadiness: parent => ({ willing: parent?.lifecycle?.stage === 'adult' }) + }, + setInterval, + clearInterval + } + }; + vm.runInNewContext(source, sandbox, { filename: filePath }); + return sandbox.module.exports; +} + +const { + SharedGuardianshipService, + getSharedGuardianshipAvailability, + getSharedGuardianshipEntryAvailability, + normalizeCode, + normalizeProjection +} = loadModule(); + +function projection(overrides = {}) { + return { + sharedCreatureId: creatureId, + runtimeId: 'creature_guardianship_demo', + name: 'Aster', + genes: { species: 'nebulaSprite' }, + lifecycle: { stage: 'baby' }, + care: { comfort: 80, curiosity: 60, energy: 70 }, + revision: 3, + status: 'active', + guardianCount: 2, + guardianRole: 'host', + guardianLabel: 'Guardian A', + notificationsMuted: false, + history: [], + ...overrides + }; +} + +function harness(rpcImpl = null) { + const rpc = jest.fn(rpcImpl || (async name => { + if (name === 'attest_shared_guardianship_eligibility') { + return { data: { eligible: true }, error: null }; + } + if (name === 'list_shared_guardianship_creatures') { + return { data: [projection()], error: null }; + } + if (name === 'get_shared_guardianship_projection' || name === 'perform_shared_guardianship_care' || name === 'set_shared_guardianship_notifications') { + return { data: projection(), error: null }; + } + return { data: { + invitationId, role: 'host', status: 'waiting', ownParentId: 'parent-1', + hostConfirmed: false, guestConfirmed: false, code: '12AB-34CD-56EF' + }, error: null }; + })); + const cloudSave = { + remoteRevision: 7, + isAgeEligible: jest.fn(() => true), + isEnabled: jest.fn(() => true), + isConfigured: jest.fn(() => true), + getAgeGroup: jest.fn(() => 'age_18_plus'), + synchronize: jest.fn(async () => undefined), + client: { + rpc, + auth: { + getSession: jest.fn(async () => ({ data: { session: null }, error: null })), + getUser: jest.fn(async () => ({ data: { user: { id: 'guardian-a' } }, error: null })) + }, + functions: { invoke: jest.fn(async () => ({ data: { status: 'staged' }, error: null })) } + } + }; + const gameState = { + values: { 'sharedGuardianship.projections': [] }, + get: jest.fn(key => gameState.values[key]), + set: jest.fn((key, value) => { gameState.values[key] = value; }) + }; + const account = { getStatus: jest.fn(async () => ({ permanent: true })) }; + return { + rpc, cloudSave, gameState, + service: new SharedGuardianshipService({ cloudSave, gameState, account }) + }; +} + +describe('SharedGuardianshipService', () => { + test('keeps under-age, disabled-cloud and anonymous profiles outside the feature', () => { + const base = harness().cloudSave; + expect(getSharedGuardianshipAvailability({ ...base, isAgeEligible: () => false })).toMatchObject({ available: false, reason: 'age_restricted' }); + expect(getSharedGuardianshipAvailability({ ...base, isEnabled: () => false })).toMatchObject({ available: false, reason: 'cloud_save_required' }); + expect(getSharedGuardianshipAvailability(base, { permanent: false })).toMatchObject({ available: false, reason: 'account_required' }); + }); + + test('lets an eligible player reach the explanation before Cloud Save is enabled', () => { + const base = harness().cloudSave; + expect(getSharedGuardianshipEntryAvailability({ + ...base, + isEnabled: () => false + })).toMatchObject({ available: true, reason: null }); + expect(getSharedGuardianshipEntryAvailability({ + ...base, + isAgeEligible: () => false + })).toMatchObject({ available: false, reason: 'age_restricted' }); + }); + + test('normalizes the 48-bit code and rejects malformed values', () => { + expect(normalizeCode('12ab 34cd 56ef')).toBe('12AB-34CD-56EF'); + expect(normalizeCode('wrong')).toBeNull(); + }); + + test('attests once and creates only from a willing adult at the current cloud revision', async () => { + const { service, rpc } = harness(); + const parent = { id: 'parent-1', lifecycle: { stage: 'adult' } }; + await service.create(parent); + await service.create(parent); + expect(rpc).toHaveBeenCalledWith('attest_shared_guardianship_eligibility', { + p_age_band: 'age_18_plus', + p_terms_version: 'shared-guardianship-2026-08-31', + p_privacy_version: 'shared-guardianship-2026-08-31' + }); + expect(rpc.mock.calls.filter(([name]) => name === 'attest_shared_guardianship_eligibility')).toHaveLength(1); + expect(rpc).toHaveBeenCalledWith('create_shared_guardianship_invitation', { + p_parent_id: 'parent-1', + p_expected_revision: 7, + p_idempotency_key: 'invite_f7d62d73663f4f3195e6d0175eb00b2a' + }); + }); + + test('uses an idempotency key and expected canonical revision for care', async () => { + const { service, rpc } = harness(); + await service.care(creatureId, 'tend', 3); + expect(rpc).toHaveBeenCalledWith('perform_shared_guardianship_care', { + p_creature_id: creatureId, + p_action: 'tend', + p_idempotency_key: 'care_tend_f7d62d73663f4f3195e6d0175eb00b2a', + p_expected_revision: 3 + }); + }); + + test('caches only validated participant projections', async () => { + const { service, gameState } = harness(); + const values = await service.refreshAll(); + expect(values).toHaveLength(1); + expect(gameState.values['sharedGuardianship.projections']).toHaveLength(1); + expect(normalizeProjection({ ...projection(), sharedCreatureId: 'forged' })).toBeNull(); + }); + + test('keeps an invitation key across an ambiguous network retry', async () => { + let attempts = 0; + const { service, rpc } = harness(async name => { + if (name === 'attest_shared_guardianship_eligibility') { + return { data: { eligible: true }, error: null }; + } + attempts += 1; + if (attempts === 1) { + return { data: null, error: { message: 'network unavailable' } }; + } + return { + data: { + invitationId, + role: 'host', + status: 'waiting', + ownParentId: 'parent-1', + code: '12AB-34CD-56EF' + }, + error: null + }; + }); + const parent = { id: 'parent-1', lifecycle: { stage: 'adult' } }; + await expect(service.create(parent)).rejects.toMatchObject({ + code: 'shared_guardianship_service_error' + }); + await service.create(parent); + const creates = rpc.mock.calls.filter(([name]) => ( + name === 'create_shared_guardianship_invitation' + )); + expect(creates).toHaveLength(2); + expect(creates[0][1].p_idempotency_key).toBe( + creates[1][1].p_idempotency_key + ); + }); + + test('turns a server conflict projection into a friendly refresh error', async () => { + const { service, gameState } = harness(async name => { + if (name === 'attest_shared_guardianship_eligibility') { + return { data: { eligible: true }, error: null }; + } + return { + data: projection({ revision: 8, conflict: true }), + error: null + }; + }); + await expect(service.care(creatureId, 'rest', 3)).rejects.toMatchObject({ + code: 'shared_guardianship_revision_conflict', + latestProjection: expect.objectContaining({ revision: 8 }) + }); + expect(gameState.values['sharedGuardianship.projections'][0].revision).toBe(8); + }); + + test('maps committed rate-limit responses as errors rather than invitations', async () => { + const { service } = harness(async name => { + if (name === 'attest_shared_guardianship_eligibility') { + return { data: { eligible: true }, error: null }; + } + return { + data: { errorCode: 'shared_guardianship_join_rate_limited' }, + error: null + }; + }); + await expect(service.join('12AB-34CD-56EF', { + id: 'parent-1', lifecycle: { stage: 'adult' } + })).rejects.toMatchObject({ code: 'shared_guardianship_join_rate_limited' }); + }); + + test('maps stale-command errors to safe player language', () => { + const { service } = harness(); + const error = service.mapError({ message: 'shared_guardianship_revision_conflict' }); + expect(error.code).toBe('shared_guardianship_revision_conflict'); + expect(error.message).not.toMatch(/database|rpc|postgres/i); + }); +}); diff --git a/src/config/legal.json b/src/config/legal.json index f6d6dc96..d61818ec 100644 --- a/src/config/legal.json +++ b/src/config/legal.json @@ -9,7 +9,7 @@ "shortNotice": "© 2026 Tech Evolve AI" }, "privacyPolicy": { - "lastUpdated": "2026-08-27", + "lastUpdated": "2026-09-01", "title": "Privacy Policy", "sections": [ { @@ -28,13 +28,17 @@ "heading": "Children's Privacy", "content": "Mythical Void is designed for players of all ages, including children. No account is required and local play remains available without Cloud Save. Cloud Save and personalized external AI features are unavailable to profiles that identify as under 16 in this release. The selected age range stays on the device. We do not intentionally request a child's email address, exact birth date, or physical location. The separate feedback pulse is only for adults aged 18 or over. It uses fixed choices, does not request child details, and rejects a submission unless the adult confirmation is selected." }, + { + "heading": "Shared Guardianship", + "content": "Shared Guardianship is optional and available only to profiles that confirm they are 16 or older. It requires private Cloud Save plus a verified email account and password. Each person contributes one willing adult creature and separately agrees before one shared child is created. The other guardian can see only the shared creature's approved name, traits, care state, neutral Guardian A or Guardian B activity summaries, and recent shared moments. They cannot see your email, password, account details, unrelated creatures, solo save, location or online presence. The feature has no chat, public profile, player search, contact import or free-text messages. Private codes expire after 30 minutes and should be sent only to someone you know." + }, { "heading": "Adult Feedback", "content": "The optional adult feedback pulse requires an adult confirmation and records eight fixed-choice answers, including broad choices for how the adult found Mythical Void and what made them want to try it, plus the submission time and current release identifier. It does not store a referring address or create an account, and it does not store a name, email address, free-text answer, exact age, user or session identifier, creature name, device detail or location in the feedback record. The fixed-choice record is kept for no more than 180 days and is available only to the protected server role. Standard Netlify and Supabase service and security logs remain subject to their own operational retention." }, { "heading": "Data Security", - "content": "Local saves remain under your device's control. Optional cloud saves are protected through authenticated access and database policies that restrict each player to their own record. Living Portrait and personalized story-video files are copied into private storage; save files contain protected asset references rather than storage paths or expiring access links. Guardian PIN data and local safety audit history are excluded from cloud uploads. You can disable Cloud Save without deleting the existing copy, or delete the remote save, protected living media, and anonymous cloud identity from the in-game settings." + "content": "Local saves remain under your device's control. Optional cloud saves and shared creatures are protected through authenticated access and database policies that restrict records to their participants. Living Portrait and personalized story-video files are copied into private storage; save files contain protected asset references rather than storage paths or expiring access links. Guardian PIN data and local safety audit history are excluded from cloud uploads. You can sign out of a Shared Guardianship account without deleting your solo local game. You can also permanently delete your account after confirming the current password and typing DELETE. Deletion ends your shared access and removes direct account attribution and protected media. A remaining guardian keeps the shared creature; with no guardian it rests in a private archive. Non-identifying lineage fingerprints may remain so the creature's origin is not falsified." }, { "heading": "Changes to This Policy", @@ -47,7 +51,7 @@ ] }, "termsOfService": { - "lastUpdated": "2026-01-23", + "lastUpdated": "2026-09-01", "title": "Terms of Service", "sections": [ { @@ -66,6 +70,10 @@ "heading": "Permitted Use", "content": "You are granted a limited, non-exclusive license to:\n• Play Mythical Void for personal, non-commercial entertainment\n• Share screenshots and videos of your gameplay (with credit to Mythical Void)\n• Stream or create content featuring the game" }, + { + "heading": "Shared Guardianship", + "content": "Shared Guardianship is an optional 16+ feature for two verified accounts. Each person contributes one willing adult creature and must separately agree before one shared child is created. Either guardian may care for the creature independently. A guardian may leave or permanently delete their own account after password confirmation, but cannot remove the other guardian, expose the other's account identity, transfer the creature or delete it while the other guardian remains. Private codes are single-use and time-limited. Do not post them publicly. The feature does not provide chat, public matching, account search, contact import, live location or online presence." + }, { "heading": "Prohibited Conduct", "content": "You agree NOT to:\n• Reverse engineer, decompile, or extract game code\n• Use automated scripts, bots, or cheats\n• Attempt to exploit bugs or glitches\n• Redistribute the game or its assets\n• Use the game for any illegal purpose\n• Misrepresent affiliation with Tech Evolve AI" diff --git a/src/config/sharedGuardianship.json b/src/config/sharedGuardianship.json new file mode 100644 index 00000000..acac2528 --- /dev/null +++ b/src/config/sharedGuardianship.json @@ -0,0 +1,29 @@ +{ + "contractVersion": "1.0", + "featureFlag": "shared_guardianship_v1", + "enabled": false, + "minimumAgeGroups": ["age_16_17", "age_18_plus"], + "requiresPermanentIdentity": true, + "requiresVerifiedEmail": true, + "maximumActiveCreaturesPerGuardian": 1, + "invitationTtlMinutes": 30, + "termsVersion": "shared-guardianship-2026-08-31", + "privacyVersion": "shared-guardianship-2026-08-31", + "participantLabels": ["Guardian A", "Guardian B"], + "safeNames": [ + "Aster", + "Beacon", + "Cinder", + "Echo", + "Lumen", + "Nova", + "Orbit", + "Solace" + ], + "storyIntegration": false, + "levelIntegration": false, + "chat": false, + "publicDiscovery": false, + "onlinePresence": false, + "freeformPeerText": false +} diff --git a/src/game.js b/src/game.js index e98e9501..7d0b91d9 100644 --- a/src/game.js +++ b/src/game.js @@ -17,6 +17,8 @@ import PlatformerLevelScene from './scenes/PlatformerLevelScene.js'; import VictoryScene from './scenes/VictoryScene.js'; import CloudSaveSettingsModal from './ui/CloudSaveSettingsModal.js'; import SharedFusionModal from './ui/SharedFusionModal.js'; +import SharedGuardianshipModal from './ui/SharedGuardianshipModal.js'; +import SharedCreatureCareModal from './ui/SharedCreatureCareModal.js'; import PageVisibilityController from './utils/PageVisibilityController.js'; // Individual levels are lazy loaded via SceneLoader when player enters them // This reduces initial bundle size by ~200KB (each level is ~40-60KB) @@ -488,7 +490,7 @@ async function initializeCloudSaves() { auth: { persistSession: true, autoRefreshToken: true, - detectSessionInUrl: false + detectSessionInUrl: true } }); @@ -498,6 +500,11 @@ async function initializeCloudSaves() { }); window.CloudSave = cloudSaveManager; + window.DurableAccountService?.installRecoveryFlow?.({ + client, + cloudSave: cloudSaveManager + }); + await cloudSaveManager.initialize(); console.info('[CloudSave] Ready:', cloudSaveManager.getStatus()); return cloudSaveManager; @@ -2253,7 +2260,7 @@ async function initializeGame() { // Local, non-saving Fusion Pod previews for maturity requirements and selection QA. const testFusion = urlParams.get('testFusion'); - if (isLocalPreview && ['eligible', 'ready', 'journey', 'blocked', 'hatch', 'consent'].includes(testFusion)) { + if (isLocalPreview && ['eligible', 'ready', 'journey', 'blocked', 'hatch', 'consent', 'guardianship'].includes(testFusion)) { game.events.once('ready', () => { setTimeout(() => { const now = Date.now(); @@ -2286,7 +2293,7 @@ async function initializeGame() { } }; }; - const previewCreatures = ['eligible', 'ready', 'journey', 'hatch', 'consent'].includes(testFusion) + const previewCreatures = ['eligible', 'ready', 'journey', 'hatch', 'consent', 'guardianship'].includes(testFusion) ? [ createPreviewCreature('preview_adult', 'Stardust', 'adult', 3, 'rare'), createPreviewCreature('preview_elder', 'Moonglow', 'elder', 12, 'epic'), @@ -2340,7 +2347,8 @@ async function initializeGame() { previewCreatures, previewAutoSelect: ['ready', 'journey'].includes(testFusion), previewAutoStart: testFusion === 'journey', - previewConsentOnly: testFusion === 'consent' + previewConsentOnly: testFusion === 'consent', + previewSharedGuardianshipAccount: testFusion === 'guardianship' }); }, 100); }); @@ -2590,6 +2598,225 @@ async function initializeGame() { showSharedFusionPreview(); } + // Local, non-saving Shared Guardianship previews for responsive and + // governance QA. These routes never call Supabase or mutate a save. + const testSharedGuardianship = urlParams.get('testSharedGuardianship'); + if ( + isLocalPreview && + [ + 'cloud', + 'account', + 'home', + 'waiting', + 'paired', + 'naming', + 'care', + 'access', + 'delete' + ].includes(testSharedGuardianship) + ) { + let guardianshipPreviewBootChecks = 0; + const showGuardianshipPreview = () => { + if (!game.isBooted) { + guardianshipPreviewBootChecks++; + if (guardianshipPreviewBootChecks <= 60) { + setTimeout(showGuardianshipPreview, 50); + } + return; + } + setTimeout(() => { + const now = Date.now(); + const parent = { + id: 'preview_guardian_parent', + name: 'Stardust', + rarity: 'rare', + generation: 1, + hatchTime: now - 6 * 24 * 60 * 60 * 1000, + lifecycle: { + birthDate: now - 6 * 24 * 60 * 60 * 1000, + stage: 'adult' + }, + stats: { happiness: 91 }, + mood: { current: 'steady' }, + genes: { + rarity: 'rare', + cosmicAffinity: { element: 'crystal' } + } + }; + const sharedCreatureId = 'shared_preview_beacon_23'; + let projection = { + sharedCreatureId, + name: 'Beacon', + rarity: 'epic', + generation: 2, + guardianCount: 2, + revision: 7, + notificationsMuted: false, + care: { + comfort: 82, + curiosity: 74, + energy: 91 + }, + history: [ + { summary: 'Stardust tended Beacon.' }, + { summary: 'The other guardian played with Beacon.' } + ] + }; + let invitation = { + invitationId: '824363b2-d374-4b44-bf7f-1d7a177fa077', + role: 'host', + status: testSharedGuardianship === 'naming' + ? 'staged' + : testSharedGuardianship, + code: testSharedGuardianship === 'waiting' + ? '23AF-BEAC-077A' + : null, + peerSignal: ['paired', 'naming'].includes(testSharedGuardianship) + ? { + rarity: 'epic', + affinity: 'verdant', + generation: 2 + } + : null, + hostConfirmed: testSharedGuardianship === 'naming', + guestConfirmed: testSharedGuardianship === 'naming', + ownNameChoice: null, + peerNameChoice: testSharedGuardianship === 'naming' + ? 'Beacon' + : null, + nameAgreed: false, + sharedCreatureId + }; + const account = { + async getStatus() { + return { + configured: true, + authenticated: true, + permanent: testSharedGuardianship !== 'account', + identityVerified: testSharedGuardianship !== 'account', + passwordReady: testSharedGuardianship !== 'account' + }; + }, + async signOut() {}, + async deleteAccount() {} + }; + const service = { + account, + async create() { + invitation = { ...invitation, status: 'waiting', code: '23AF-BEAC-077A' }; + return invitation; + }, + async join() { + invitation = { + ...invitation, + status: 'paired', + code: null, + peerSignal: { + rarity: 'epic', + affinity: 'verdant', + generation: 2 + } + }; + return invitation; + }, + async get() { return invitation; }, + async confirm() { return invitation; }, + async execute() { return invitation; }, + async chooseName(_invitationId, name) { + invitation = { + ...invitation, + ownNameChoice: name, + nameAgreed: name === invitation.peerNameChoice, + status: name === invitation.peerNameChoice + ? 'committed' + : invitation.status + }; + return invitation; + }, + async cancel() { return invitation; }, + async getProjection() { return projection; }, + async care(_creatureId, action) { + const careKey = action === 'play' + ? 'curiosity' + : action === 'rest' + ? 'energy' + : 'comfort'; + projection = { + ...projection, + revision: projection.revision + 1, + care: { + ...projection.care, + [careKey]: Math.min(100, projection.care[careKey] + 8) + }, + history: [ + { summary: `You chose ${action} for Beacon.` }, + ...projection.history + ] + }; + return projection; + }, + async setNotificationsMuted(_creatureId, muted) { + projection = { + ...projection, + revision: projection.revision + 1, + notificationsMuted: muted + }; + return projection; + }, + async leave() {}, + watch() { return () => {}; }, + destroy() {} + }; + + game.scene.stop('HatchingScene'); + game.scene.start('FusionPodScene', { + previewCreatures: [parent, { ...parent, id: 'preview_guardian_peer', name: 'Moonglow' }] + }); + setTimeout(() => { + const scene = game.scene.getScene('FusionPodScene'); + window.__sharedGuardianshipPreview?.destroy?.(); + if (['care', 'access', 'delete'].includes(testSharedGuardianship)) { + const modal = new SharedCreatureCareModal(scene, { + service, + projection + }); + window.__sharedGuardianshipPreview = modal; + modal.show(); + if (testSharedGuardianship !== 'care') { + modal.manageAccess = true; + modal.confirmDeleteAccount = testSharedGuardianship === 'delete'; + modal.render(); + } + return; + } + const cloudSave = { + isEnabled: () => testSharedGuardianship !== 'cloud', + async enable() {} + }; + const modal = new SharedGuardianshipModal(scene, { + account, + service, + cloudSave, + previewAccess: true + }); + window.__sharedGuardianshipPreview = modal; + modal.show({ parents: [parent] }); + setTimeout(() => { + if (testSharedGuardianship === 'cloud') { + modal.renderCloudGate(); + } else if (testSharedGuardianship === 'home') { + modal.renderHome(); + } else if (['waiting', 'paired', 'naming'].includes(testSharedGuardianship)) { + modal.invitation = invitation; + modal.renderInvitation(); + } + }, 20); + }, 150); + }, 100); + }; + showGuardianshipPreview(); + } + if (!hasLocalQaRoute) { game.events.once('ready', () => { setTimeout(() => { diff --git a/src/global-init.js b/src/global-init.js index fe2d590d..ca5b265c 100644 --- a/src/global-init.js +++ b/src/global-init.js @@ -45,6 +45,8 @@ import './systems/UXEnhancements.js'; import './systems/FusionConsent.js'; import './systems/FusionAuthority.js'; import './systems/SharedFusionInvitationService.js'; +import './systems/DurableAccountService.js'; +import './systems/SharedGuardianshipService.js'; import './systems/KidMode.js'; import './systems/AudioManager.js'; import './systems/FXLibrary.js'; diff --git a/src/scenes/FusionPodScene.js b/src/scenes/FusionPodScene.js index 0a3cda70..b1fee7fb 100644 --- a/src/scenes/FusionPodScene.js +++ b/src/scenes/FusionPodScene.js @@ -14,6 +14,7 @@ import SceneTransitionHelper from '../utils/SceneTransitionHelper.js'; import FusionConsentModal from '../ui/FusionConsentModal.js'; import SharedFusionModal from '../ui/SharedFusionModal.js'; +import SharedGuardianshipModal from '../ui/SharedGuardianshipModal.js'; const Phaser = typeof window !== 'undefined' ? window.Phaser : undefined; const FUSION_ELIGIBLE_STAGES = new Set(['adult', 'elder']); const FUSION_ADULT_AGE_MS = 2 * 24 * 60 * 60 * 1000; @@ -183,9 +184,11 @@ class FusionPodScene extends Phaser.Scene { this.cleanupComplete = false; this.fusionConsentModal = null; this.sharedFusionModal = null; + this.sharedGuardianshipModal = null; this.fusionConsentReceipt = null; this.previewConsentOnly = false; this.previewSharedFusionAvailable = false; + this.previewSharedGuardianshipAccount = false; } init(data = {}) { @@ -217,6 +220,10 @@ class FusionPodScene extends Phaser.Scene { this.previewCreatures && data.previewSharedFusionAvailable ); + this.previewSharedGuardianshipAccount = Boolean( + this.previewCreatures && + data.previewSharedGuardianshipAccount + ); } getFusionCollection() { @@ -275,7 +282,8 @@ class FusionPodScene extends Phaser.Scene { .filter(entry => entry.eligible) .map(entry => entry.creature); const sharedFusionAvailable = - this.isSharedFusionAvailable(); + this.isSharedFusionAvailable() || + this.isSharedGuardianshipAvailable(); if (collection.length < 2 && !sharedFusionAvailable) { this.showRequirementNotMet(width, height, 'need_creatures', { @@ -306,6 +314,10 @@ class FusionPodScene extends Phaser.Scene { this.createSharedFusionButton(); this.createCloseButton(width); + if (this.previewSharedGuardianshipAccount) { + this.time.delayedCall(80, () => this.openSharedGuardianship()); + } + if (this.previewConsentOnly) { this.time.delayedCall(80, () => { const eligible = this.getAdultCreatures( @@ -393,8 +405,20 @@ class FusionPodScene extends Phaser.Scene { )?.available === true; } + isSharedGuardianshipAvailable() { + if (this.previewSharedGuardianshipAccount) return true; + if (this.previewSharedFusionAvailable) return false; + if (this.previewCreatures || !window.SharedGuardianship?.isEnabled?.()) { + return false; + } + return window.SharedGuardianship + .getSharedGuardianshipEntryAvailability?.(window.CloudSave) + ?.available === true; + } + createSharedFusionButton() { - if (!this.isSharedFusionAvailable()) return; + const sharedGuardianshipAvailable = this.isSharedGuardianshipAvailable(); + if (!sharedGuardianshipAvailable && !this.isSharedFusionAvailable()) return; const width = 58; const height = 50; const x = this.panelBounds.x + 7; @@ -431,7 +455,7 @@ class FusionPodScene extends Phaser.Scene { const label = this.add.text( x + width / 2, y + height / 2, - 'LINK', + sharedGuardianshipAvailable ? 'SHARE' : 'LINK', { fontSize: '10px', color: '#FFFFFF', @@ -448,14 +472,17 @@ class FusionPodScene extends Phaser.Scene { .setInteractive({ useHandCursor: true }); let tooltip = null; hitZone.on('pointerdown', () => { - this.openSharedFusion(); + if (sharedGuardianshipAvailable) this.openSharedGuardianship(); + else this.openSharedFusion(); }); hitZone.on('pointerover', () => { draw(true); tooltip = this.add.text( x, y - 8, - 'Protected Shared Fusion', + sharedGuardianshipAvailable + ? 'One creature in two Sanctuaries' + : 'Protected Shared Fusion', { fontSize: '10px', color: '#FFFFFF', @@ -472,6 +499,46 @@ class FusionPodScene extends Phaser.Scene { this.elements.push(background, label, hitZone); } + openSharedGuardianship() { + if ( + this.sharedGuardianshipModal || + !this.isSharedGuardianshipAvailable() + ) { + return false; + } + const parents = this.getFusionCollection().filter(creature => ( + window.FusionConsent + ?.getFusionCompanionReadiness?.(creature)?.willing + )); + const previewAccount = this.previewSharedGuardianshipAccount + ? { + getStatus: async () => ({ + configured: true, + authenticated: true, + permanent: false, + verified: false, + anonymous: true + }) + } + : null; + this.sharedGuardianshipModal = new SharedGuardianshipModal(this, { + ...(previewAccount + ? { account: previewAccount, previewAccess: true } + : {}) + }); + return this.sharedGuardianshipModal.show({ + parents, + onClose: () => { + this.sharedGuardianshipModal = null; + this.updateBreedButton(); + }, + onComplete: () => { + this.sharedGuardianshipModal = null; + this.closeScene(); + } + }); + } + openSharedFusion() { if (this.sharedFusionModal || !this.isSharedFusionAvailable()) { return false; @@ -1718,7 +1785,10 @@ class FusionPodScene extends Phaser.Scene { createBreedButton(width, height) { const buttonY = this.layout.action.y; - const sharedLinkLane = this.isSharedFusionAvailable() ? 65 : 0; + const sharedLinkLane = ( + this.isSharedFusionAvailable() || + this.isSharedGuardianshipAvailable() + ) ? 65 : 0; const actionX = this.layout.action.x + sharedLinkLane; const actionWidth = this.layout.action.width - sharedLinkLane; const centerX = actionX + actionWidth / 2; @@ -3056,6 +3126,8 @@ class FusionPodScene extends Phaser.Scene { this.fusionConsentModal = null; this.sharedFusionModal?.destroy?.(); this.sharedFusionModal = null; + this.sharedGuardianshipModal?.destroy?.(); + this.sharedGuardianshipModal = null; this.elements.forEach(el => { try { diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js index 50f887c9..80735b43 100644 --- a/src/scenes/GameScene.js +++ b/src/scenes/GameScene.js @@ -90,6 +90,7 @@ import CompanionEarthMemoryModal from '../ui/CompanionEarthMemoryModal.js'; import SenseiMemoryModal from '../ui/SenseiMemoryModal.js'; import ShipEvidenceBoardModal from '../ui/ShipEvidenceBoardModal.js'; import CurrentVeilModal from '../ui/CurrentVeilModal.js'; +import SharedCreatureCareModal from '../ui/SharedCreatureCareModal.js'; import VillageCommandPanel from '../ui/VillageCommandPanel.js'; import { recordCampaignLegacyCapsule } from '../systems/CampaignLegacy.js'; import { getHomecomingHandoffSnapshot } from '../systems/HomecomingHandoff.js'; @@ -301,6 +302,11 @@ class GameScene extends Phaser.Scene { this.senseiMemoryModal = null; this.shipEvidenceBoardModal = null; this.currentVeilModal = null; + this.sharedCreatureCareModal = null; + this.sharedGuardianshipService = null; + this.sharedGuardianshipStopWatching = null; + this.sharedGuardianshipHabitatElements = []; + this.sharedGuardianshipProjection = null; this.recoveryLogModal = null; this.shipEvidencePreview = null; this.shipEvidencePreviewSize = null; @@ -1194,6 +1200,7 @@ class GameScene extends Phaser.Scene { this.createExpeditionAstronaut(); this.createLivingSignals(); this.trackWorldArrival(); + void this.initializeSharedGuardianshipHabitat(); // Set up camera to follow player this.setupCamera(); @@ -8034,6 +8041,174 @@ class GameScene extends Phaser.Scene { } } + async initializeSharedGuardianshipHabitat() { + if ( + this.currentBiome !== 'nebula' || + !window.SharedGuardianship?.isEnabled?.() || + !window.SharedGuardianshipService + ) { + return; + } + this.sharedGuardianshipService?.destroy?.(); + this.sharedGuardianshipService = new window.SharedGuardianshipService({ + cloudSave: window.CloudSave, + gameState: window.GameState + }); + try { + const availability = await this.sharedGuardianshipService.getAvailability(); + if (!availability.accountStatus?.permanent) return; + const cached = window.GameState?.get?.( + 'sharedGuardianship.projections' + )?.[0]; + if (cached) this.renderSharedGuardianshipHabitat(cached); + const projections = await this.sharedGuardianshipService.refreshAll(); + const projection = projections[0] || null; + if (!projection) { + this.destroySharedGuardianshipHabitat(); + return; + } + this.renderSharedGuardianshipHabitat(projection); + this.sharedGuardianshipStopWatching = + this.sharedGuardianshipService.watch( + projection.sharedCreatureId, + (next, error) => { + if (next) this.updateSharedGuardianshipProjection(next); + else if (error) { + this.sharedGuardianshipHabitatStatus?.setText?.( + 'CONNECTION PAUSED' + ); + } + } + ); + } catch (error) { + console.warn( + '[GameScene] Shared habitat is using its last safe state:', + error?.message + ); + } + } + + getSharedGuardianshipHabitatPosition() { + const anchor = this.fusionPodLandmark?.zone || + this.villageHeartLandmark?.zone || + { x: this.worldWidth / 2, y: this.worldHeight / 2 }; + const candidate = { + x: Phaser.Math.Clamp(Number(anchor.x) + 230, 180, this.worldWidth - 180), + y: Phaser.Math.Clamp(Number(anchor.y) + 90, 180, this.worldHeight - 180) + }; + return this.sanctuaryZones?.getSafeSpawnPosition?.(candidate) || candidate; + } + + renderSharedGuardianshipHabitat(projection) { + this.destroySharedGuardianshipHabitat(); + if (!projection?.genes || this._isShuttingDown) return; + this.sharedGuardianshipProjection = projection; + const { x, y } = this.getSharedGuardianshipHabitatPosition(); + const ground = this.add.graphics().setDepth(y - 18); + ground.fillStyle(0x071411, 0.74); + ground.fillEllipse(x, y + 18, 170, 68); + ground.lineStyle(3, 0x71e6b1, 0.7); + ground.strokeEllipse(x, y + 18, 170, 68); + ground.lineStyle(1, 0xf2c14e, 0.48); + ground.strokeEllipse(x, y + 18, 118, 44); + + const stage = projection.lifecycle?.stage || 'baby'; + const render = this.graphicsEngine.createRandomizedSpaceMythicCreature( + projection.genes, + 0, + stage + ); + const shadow = this.add.ellipse(x, y + 16, 64, 20, 0x000000, 0.42) + .setDepth(y - 4); + const creature = this.add.sprite(x, y - 17, render.textureName) + .setScale(0.92) + .setDepth(y + 2) + .setInteractive({ useHandCursor: true }); + const name = this.add.text(x, y + 57, projection.name.toUpperCase(), { + fontSize: '13px', + color: '#F4F7F6', + fontStyle: 'bold', + backgroundColor: '#071411CC', + padding: { x: 8, y: 4 } + }).setOrigin(0.5).setDepth(y + 5); + const status = this.add.text(x, y + 79, 'SHARED HABITAT // TAP TO CARE', { + fontSize: '10px', + color: '#8FE3CF', + fontStyle: 'bold' + }).setOrigin(0.5).setDepth(y + 5); + const zone = this.add.zone(x, y, 190, 130) + .setDepth(y + 8) + .setInteractive({ useHandCursor: true }); + const open = () => this.openSharedCreatureCare(); + zone.on('pointerdown', open); + creature.on('pointerdown', open); + const idleTween = this.tweens.add({ + targets: creature, + y: creature.y - 5, + duration: 1350, + yoyo: true, + repeat: -1, + ease: 'Sine.InOut' + }); + this.sharedGuardianshipHabitatStatus = status; + this.sharedGuardianshipHabitatElements = [ + ground, + shadow, + creature, + name, + status, + zone + ]; + this.sharedGuardianshipHabitatTween = idleTween; + } + + updateSharedGuardianshipProjection(projection) { + if (!projection || this._isShuttingDown) return; + const identityChanged = + this.sharedGuardianshipProjection?.sharedCreatureId !== + projection.sharedCreatureId || + this.sharedGuardianshipProjection?.name !== projection.name; + this.sharedGuardianshipProjection = projection; + this.sharedGuardianshipHabitatStatus?.setText?.( + 'SHARED HABITAT // TOGETHER' + ); + if (identityChanged) this.renderSharedGuardianshipHabitat(projection); + } + + openSharedCreatureCare() { + if ( + this.sharedCreatureCareModal || + !this.sharedGuardianshipProjection || + !this.sharedGuardianshipService + ) return false; + this.sharedCreatureCareModal = new SharedCreatureCareModal(this, { + service: this.sharedGuardianshipService, + projection: this.sharedGuardianshipProjection, + onUpdate: projection => this.updateSharedGuardianshipProjection(projection), + onAccessRevoked: () => { + this.sharedGuardianshipStopWatching?.(); + this.sharedGuardianshipStopWatching = null; + this.sharedGuardianshipProjection = null; + this.destroySharedGuardianshipHabitat(); + }, + onClose: () => { + this.sharedCreatureCareModal = null; + } + }); + return this.sharedCreatureCareModal.show(); + } + + destroySharedGuardianshipHabitat() { + this.sharedGuardianshipHabitatTween?.stop?.(); + this.sharedGuardianshipHabitatTween = null; + this.sharedGuardianshipHabitatElements?.forEach(element => { + element?.removeAllListeners?.(); + element?.destroy?.(); + }); + this.sharedGuardianshipHabitatElements = []; + this.sharedGuardianshipHabitatStatus = null; + } + /** * Refresh roster indicator display */ @@ -18220,6 +18395,13 @@ class GameScene extends Phaser.Scene { this.shipEvidenceBoardModal = null; this.currentVeilModal?.destroy?.(); this.currentVeilModal = null; + this.sharedCreatureCareModal?.destroy?.(); + this.sharedCreatureCareModal = null; + this.sharedGuardianshipStopWatching?.(); + this.sharedGuardianshipStopWatching = null; + this.sharedGuardianshipService?.destroy?.(); + this.sharedGuardianshipService = null; + this.destroySharedGuardianshipHabitat(); this.villageCommandPanel?.destroy?.(); this.villageCommandPanel = null; this.villageReconcileTimer?.remove?.(); diff --git a/src/site/storefront.js b/src/site/storefront.js index f646543f..b4ff9ad9 100644 --- a/src/site/storefront.js +++ b/src/site/storefront.js @@ -219,6 +219,11 @@ function renderLegalPage(type) {

You do not need an account, email address, or payment information to begin playing Mythical Void.

Game saves

Your creature and progress stay in this browser by default. If you choose to turn on Cloud Save, an anonymous account number, your saved game, and the times it was saved are stored securely. A parent PIN and local safety history are never included.

+

Shared Guardianship

+

Shared Guardianship is an optional connected feature for players who confirm they are 16 or older. It requires a verified email account and password so the same shared creature can safely return on both guardians' devices. Email and credentials stay in Supabase Auth and are never shown to the other guardian.

+

A guardian can permanently delete their account after confirming the current password and typing an explicit deletion phrase. Deletion ends that guardian's access, removes direct account attribution and protected media, and signs the device out. A remaining guardian keeps the shared creature; if nobody remains, it rests in the private archive. Non-identifying lineage fingerprints may remain so the creature's origin is not falsified.

+

Each guardian can see the shared creature's approved name, traits, care state, neutral Guardian A or Guardian B activity summaries, and recent shared moments. The feature has no chat, public profile, player search, location sharing, contact import or online-presence display. Private invitation codes expire after 30 minutes and should only be sent to someone you know.

+

Expired and cancelled invitation records are retained for no more than 30 days, join-attempt records for no more than 24 hours, and player-visible care history is bounded to the latest 100 events. A shared creature remains while at least one guardian keeps it. Leaving removes that guardian's access and direct attribution; deleting an account revokes its participation.

Online services

Cloud Save uses Supabase. The game may also ask trusted outside services for public space-weather or game-help information. Like most online services, they may receive basic connection information such as an internet address.

Optional website analytics

@@ -226,13 +231,16 @@ function renderLegalPage(type) {

Playing now signal

The small “playing now” signal on the public website is an approximate count of game tabs that have checked in during the previous 90 seconds. A game tab sends a temporary random code; the server immediately turns it into a one-way value and keeps only that value and its latest check-in time. It does not include a name, account, creature, game choices or Google Analytics data. A check-in stops counting after 90 seconds and is deleted during a later signal check. The website shows a range rather than inventing players or exposing an exact crowd.

Children

-

Children can play without Cloud Save. A child should only use Cloud Save when a parent or guardian has given any permission required where they live.

+

Children can play the solo game without an account. Cloud Save is available only to profiles that confirm they are 16 or older. Shared Guardianship also requires a verified account and is unavailable to under-16 profiles.

Contact

Our parent and guardian contact channel is being prepared. When it opens, messages will be used only to reply and to keep young players safe.

` : `

Mythical Void is an early-access browser game for personal play.

Your save

Your progress stays in this browser by default. Cloud Save may be available, but we cannot promise that it will always work without interruption.

+

Shared Guardianship

+

This optional 16+ feature creates one shared creature for two verified accounts. Each person contributes one willing adult creature and must separately agree before the child is created. Either guardian may care for the shared creature independently. A guardian may leave or permanently delete their own account after password confirmation, but cannot remove the other guardian, transfer the creature, expose the other's account identity or delete the creature while the other guardian remains.

+

Private codes are single-use and time-limited. Do not post them publicly. Shared Guardianship does not provide chat, public matching, account search or live location and presence.

Play fairly

Please do not disrupt the game, pretend to be the Mythical Void team, or sell the game's artwork and content as your own.

Early access

diff --git a/src/styles/main.css b/src/styles/main.css index c73ad09b..e86c1fa5 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -11,6 +11,369 @@ --color-stellar-white: #F8F8FF; } +/* Shared Guardianship stays visually quiet and account-safe inside the Pod. */ +.shared-guardianship-modal { + position: relative; + display: grid; + place-items: center; + box-sizing: border-box; + padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom)); + color: #f4f7f6; + background: rgba(2, 8, 12, 0.88); + font-family: "Rajdhani", "Arial", sans-serif; + opacity: 0; + transition: opacity 160ms ease; +} + +.shared-guardianship-modal.is-visible { opacity: 1; } + +.shared-guardianship-shell { + width: min(680px, 100%); + max-height: min(720px, 100%); + overflow: hidden; + border: 1px solid rgba(113, 230, 177, 0.72); + border-radius: 8px; + background: #081316; + box-shadow: 0 22px 72px rgba(0, 0, 0, 0.54); +} + +.shared-guardianship-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 18px 20px 14px; + border-bottom: 1px solid rgba(143, 227, 207, 0.28); + background: #0d2020; +} + +.shared-guardianship-eyebrow, +.shared-guardianship-label, +.shared-guardianship-boundary { + margin: 0; + color: #8fe3cf; + font-size: 12px; + font-weight: 700; + line-height: 1.25; + text-transform: uppercase; +} + +.shared-guardianship-title { + margin: 3px 0 0; + color: #ffffff; + font-size: 25px; + line-height: 1.1; +} + +.shared-guardianship-close, +.shared-guardianship-text-button { + border: 0; + color: #c6d3d1; + background: transparent; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.shared-guardianship-body { + display: grid; + gap: 14px; + max-height: min(620px, calc(100vh - 120px)); + overflow: auto; + box-sizing: border-box; + padding: 18px 20px 22px; + overscroll-behavior: contain; +} + +.shared-guardianship-intro h3, +.shared-guardianship-complete h3, +.shared-guardianship-naming h3 { + margin: 0 0 8px; + color: #ffffff; + font-size: 22px; + line-height: 1.15; +} + +.shared-guardianship-intro p, +.shared-guardianship-complete p, +.shared-guardianship-naming p, +.shared-guardianship-helper, +.shared-guardianship-peer { + margin: 0 0 10px; + color: #c9d3d4; + font-size: 14px; + line-height: 1.42; +} + +.shared-guardianship-tabs, +.shared-guardianship-name-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.shared-guardianship-name-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } + +.shared-guardianship-tab, +.shared-guardianship-name { + min-height: 42px; + border: 1px solid #36515a; + border-radius: 6px; + color: #d7e0e1; + background: #0b1a20; + font: inherit; + font-weight: 700; + cursor: pointer; +} + +.shared-guardianship-tab.is-active, +.shared-guardianship-name.is-selected { + border-color: #f2c14e; + color: #071411; + background: #f2c14e; +} + +.shared-guardianship-form, +.shared-guardianship-parent-list { + display: grid; + gap: 9px; +} + +.shared-guardianship-empty, +.shared-guardianship-access { + margin: 0; + padding: 14px; + border: 1px solid rgba(143, 227, 207, 0.24); + border-radius: 6px; + color: #d4dfdc; + background: #0b1a20; + font-size: 14px; + line-height: 1.45; +} + +.shared-guardianship-access { + display: grid; + gap: 10px; +} + +.shared-guardianship-access h3, +.shared-guardianship-access p { + margin: 0; +} + +.shared-guardianship-care-grid, +.shared-guardianship-care-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.shared-guardianship-care, +.shared-guardianship-stat { + min-height: 58px; + border: 1px solid #365b61; + border-radius: 6px; + color: #eaf1ef; + white-space: pre-line; + background: #0d2326; + font: inherit; + font-weight: 700; +} + +.shared-guardianship-care { cursor: pointer; } +.shared-guardianship-stat { display: grid; place-items: center; align-content: center; gap: 3px; } +.shared-guardianship-stat span { color: #8fe3cf; font-size: 10px; } +.shared-guardianship-stat strong { font-size: 20px; } +.shared-guardianship-history h3 { margin: 0 0 7px; font-size: 16px; } +.shared-guardianship-history p { margin: 0; padding: 6px 0; border-bottom: 1px solid rgba(143, 227, 207, 0.12); color: #c9d3d4; font-size: 13px; } + +.shared-guardianship-notification-setting { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + color: #d6e3df; + font-size: 14px; +} + +.shared-guardianship-notification-setting input { + width: 20px; + height: 20px; + accent-color: #5ee6a8; +} + +.shared-guardianship-policy-links a { + color: #8fe3cf; + text-decoration: underline; + text-underline-offset: 2px; +} + +.durable-account-recovery { + position: fixed; + inset: 0; + z-index: 30000; + display: grid; + place-items: center; + padding: 20px; + background: rgba(2, 8, 13, 0.88); +} + +.durable-account-recovery__panel { + width: min(440px, 100%); + padding: 24px; + border: 1px solid #5ee6a8; + border-radius: 8px; + color: #eef7f4; + background: #0a171d; + box-shadow: 0 18px 55px rgba(0, 0, 0, 0.55); +} + +.durable-account-recovery__panel h1 { margin: 4px 0 10px; font-size: 24px; } +.durable-account-recovery__panel label { display: grid; gap: 6px; margin: 12px 0; } +.durable-account-recovery__panel input { + min-height: 46px; + padding: 10px 12px; + border: 1px solid #49646a; + border-radius: 5px; + color: #fff; + background: #061014; + font: inherit; +} + +.shared-guardianship-parent-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + max-height: 178px; + overflow: auto; +} + +.shared-guardianship-parent { + display: grid; + gap: 3px; + min-height: 58px; + padding: 9px 12px; + border: 1px solid #31505a; + border-radius: 6px; + color: #e9eeee; + text-align: left; + background: #0a181d; + cursor: pointer; +} + +.shared-guardianship-parent span { color: #90a6aa; font-size: 12px; } +.shared-guardianship-parent.is-selected { border-color: #71e6b1; background: #102b26; } + +.shared-guardianship-input, +.shared-guardianship-primary, +.shared-guardianship-secondary { + box-sizing: border-box; + width: 100%; + min-height: 48px; + border-radius: 6px; + font: inherit; +} + +.shared-guardianship-input { + border: 1px solid #446069; + padding: 0 13px; + color: #ffffff; + background: #040c10; +} + +.shared-guardianship-primary, +.shared-guardianship-secondary { + border: 1px solid #71e6b1; + padding: 9px 14px; + color: #04110d; + background: #71e6b1; + font-weight: 800; + cursor: pointer; +} + +.shared-guardianship-secondary { color: #e9eeee; background: #10262a; } +.shared-guardianship-primary:disabled { opacity: 0.42; cursor: default; } + +.shared-guardianship-modal button:focus-visible, +.shared-guardianship-modal input:focus-visible, +.shared-guardianship-modal a:focus-visible, +.durable-account-recovery button:focus-visible, +.durable-account-recovery input:focus-visible { + outline: 3px solid #ffffff; + outline-offset: 2px; +} + +.shared-guardianship-consent { + display: grid; + grid-template-columns: 24px 1fr; + gap: 10px; + align-items: start; + padding: 12px; + border: 1px solid #33484c; + border-radius: 6px; + color: #d7e0e1; + font-size: 14px; + line-height: 1.4; + background: #0a1518; +} + +.shared-guardianship-consent input { width: 20px; height: 20px; } + +.shared-guardianship-code { + display: block; + margin: 14px 0; + color: #f2c14e; + font-size: clamp(22px, 6vw, 34px); + letter-spacing: 0; + text-align: center; +} + +.shared-guardianship-notice { + margin: 0; + padding: 10px 12px; + border-left: 3px solid #f2c14e; + color: #f4e1a3; + background: rgba(242, 193, 78, 0.09); + font-size: 13px; + line-height: 1.35; +} + +.shared-guardianship-busy { + display: grid; + min-height: 220px; + place-items: center; + align-content: center; + gap: 14px; + color: #dce8e5; + text-align: center; +} + +.shared-guardianship-pulse { + width: 34px; + height: 34px; + border: 3px solid rgba(113, 230, 177, 0.24); + border-top-color: #71e6b1; + border-radius: 50%; + animation: shared-guardianship-spin 800ms linear infinite; +} + +@keyframes shared-guardianship-spin { to { transform: rotate(360deg); } } + +@media (max-width: 520px) { + .shared-guardianship-modal { padding: max(6px, env(safe-area-inset-top)) 6px max(6px, env(safe-area-inset-bottom)); } + .shared-guardianship-shell { width: 100%; max-height: 100%; } + .shared-guardianship-header { padding: 13px 12px 10px; } + .shared-guardianship-title { font-size: 18px; } + .shared-guardianship-body { gap: 10px; max-height: calc(100vh - 82px); padding: 12px; } + .shared-guardianship-parent-list, + .shared-guardianship-name-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .shared-guardianship-care-grid, + .shared-guardianship-care-summary { gap: 6px; } + .shared-guardianship-care, + .shared-guardianship-stat { min-height: 64px; padding: 7px 4px; font-size: 12px; } + .shared-guardianship-intro h3, + .shared-guardianship-complete h3, + .shared-guardianship-naming h3 { font-size: 19px; } +} + :root { --sat: env(safe-area-inset-top, 0px); --sar: env(safe-area-inset-right, 0px); diff --git a/src/systems/CloudSaveManager.js b/src/systems/CloudSaveManager.js index e1fca4b2..753e3114 100644 --- a/src/systems/CloudSaveManager.js +++ b/src/systems/CloudSaveManager.js @@ -169,6 +169,48 @@ class CloudSaveManager { return user; } + async adoptAuthenticatedSession(user, options = {}) { + const { preferRemote = true } = options; + if (!user?.id) { + throw new Error('A verified account session is required.'); + } + + this.clearSyncTimer(); + this.pendingSave = null; + this.currentUser = user; + this.remoteRevision = 0; + this.lastError = null; + this.lastConflict = null; + + if (!this.isEnabled() || !this.isConfigured()) { + return this.getStatus(); + } + if (!preferRemote) { + return this.synchronize(); + } + + this.status = 'syncing'; + try { + await this.withSyncLock(async () => { + const remoteSave = await this.fetchRemoteSave(user.id); + if (remoteSave) { + this.remoteRevision = Number(remoteSave.revision) || 0; + await this.restoreRemoteSave(remoteSave.game_state); + } else { + await this.upload(this.createLocalSnapshot(), 0); + } + }); + } catch (error) { + this.status = this.isRevisionConflict(error) ? 'conflict' : 'error'; + this.lastError = error; + this.lastConflict = this.isRevisionConflict(error) + ? this.normalizeRevisionConflict(error) + : null; + throw error; + } + return this.getStatus(); + } + async synchronize() { if (!this.isEnabled() || !this.isConfigured()) { return this.getStatus(); diff --git a/src/systems/DurableAccountService.js b/src/systems/DurableAccountService.js new file mode 100644 index 00000000..e1f9ad4a --- /dev/null +++ b/src/systems/DurableAccountService.js @@ -0,0 +1,409 @@ +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_PASSWORD_LENGTH = 10; + +export class DurableAccountError extends Error { + constructor(code, message, cause = null) { + super(message, cause ? { cause } : undefined); + this.name = 'DurableAccountError'; + this.code = code; + } +} + +function normalizedEmail(value) { + const email = String(value || '').trim().toLowerCase(); + return email.length <= 254 && EMAIL_PATTERN.test(email) ? email : null; +} + +function normalizedPassword(value) { + const password = String(value || ''); + return password.length >= MIN_PASSWORD_LENGTH && password.length <= 128 + ? password + : null; +} + +export class DurableAccountService { + constructor(options = {}) { + this.client = options.client || window.CloudSave?.client || null; + this.cloudSave = options.cloudSave || window.CloudSave || null; + } + + isConfigured() { + return Boolean(this.client?.auth); + } + + normalizeError(error, fallback = 'Account service is unavailable.') { + const source = String(error?.message || error?.code || '').toLowerCase(); + if (source.includes('manual linking')) { + return new DurableAccountError( + 'manual_linking_unavailable', + 'Account creation is not ready yet. Your local game is safe.', + error + ); + } + if (source.includes('rate limit') || source.includes('email rate')) { + return new DurableAccountError( + 'email_rate_limited', + 'Too many email requests. Wait a few minutes and try again.', + error + ); + } + if (source.includes('already') || source.includes('registered')) { + return new DurableAccountError( + 'account_exists', + 'That email already has an account. Choose Sign in instead.', + error + ); + } + if (source.includes('invalid login')) { + return new DurableAccountError( + 'invalid_login', + 'The email or password was not accepted.', + error + ); + } + if (source.includes('deletion requires recent')) { + return new DurableAccountError( + 'account_deletion_reauthentication_required', + 'Sign in with the current password again, then retry deletion.', + error + ); + } + if (source.includes('expired') || source.includes('token')) { + return new DurableAccountError( + 'verification_invalid', + 'That verification code has expired or is not valid.', + error + ); + } + if (source.includes('password')) { + return new DurableAccountError( + 'password_invalid', + `Use a password of at least ${MIN_PASSWORD_LENGTH} characters.`, + error + ); + } + return new DurableAccountError('account_service_error', fallback, error); + } + + async getStatus({ refresh = false } = {}) { + if (!this.isConfigured()) { + return { + configured: false, + authenticated: false, + permanent: false, + verified: false, + anonymous: false + }; + } + const result = refresh + ? await this.client.auth.getUser() + : await this.client.auth.getSession(); + if (result.error) throw this.normalizeError(result.error); + const user = refresh + ? result.data?.user + : result.data?.session?.user; + const identityVerified = Boolean( + user?.id && + user.is_anonymous !== true && + user.email && + user.email_confirmed_at + ); + const passwordReady = user?.user_metadata + ?.mythical_void_password_ready === true; + return { + configured: true, + authenticated: Boolean(user?.id), + permanent: identityVerified && passwordReady, + identityVerified, + passwordReady, + verified: Boolean(user?.email_confirmed_at), + anonymous: user?.is_anonymous === true, + userId: user?.id || null + }; + } + + async beginUpgrade(value) { + const email = normalizedEmail(value); + if (!email) { + throw new DurableAccountError( + 'email_invalid', + 'Enter a valid email address.' + ); + } + const user = await this.cloudSave?.ensureSession?.(); + if (!user?.id) { + throw new DurableAccountError( + 'anonymous_identity_missing', + 'Enable Cloud Save before creating an account.' + ); + } + const status = await this.getStatus({ refresh: true }); + if (status.permanent) return { emailSent: false, alreadyPermanent: true }; + if (status.identityVerified) { + return { + emailSent: false, + alreadyPermanent: false, + passwordRequired: true + }; + } + const { error } = await this.client.auth.updateUser( + { email }, + { + emailRedirectTo: + `${window.location.origin}/play/?sharedGuardianshipAccount=1` + } + ); + if (error) throw this.normalizeError(error, 'Verification email could not be sent.'); + return { emailSent: true, alreadyPermanent: false }; + } + + async verifyEmailCode(value, tokenValue) { + const email = normalizedEmail(value); + const token = String(tokenValue || '').trim(); + if (!email || !/^\d{6,10}$/.test(token)) { + throw new DurableAccountError( + 'verification_invalid', + 'Enter the verification code from the email.' + ); + } + const { error } = await this.client.auth.verifyOtp({ + email, + token, + type: 'email_change' + }); + if (error) throw this.normalizeError(error); + return this.getStatus({ refresh: true }); + } + + async finishUpgrade(passwordValue) { + const password = normalizedPassword(passwordValue); + if (!password) { + throw new DurableAccountError( + 'password_invalid', + `Use a password of at least ${MIN_PASSWORD_LENGTH} characters.` + ); + } + const status = await this.getStatus({ refresh: true }); + if (!status.verified || status.anonymous) { + throw new DurableAccountError( + 'email_not_verified', + 'Verify the email before choosing a password.' + ); + } + const { data, error } = await this.client.auth.updateUser({ + password, + data: { mythical_void_password_ready: true } + }); + if (error) throw this.normalizeError(error); + if (data?.user?.id && this.cloudSave?.adoptAuthenticatedSession) { + await this.cloudSave.adoptAuthenticatedSession(data.user, { + preferRemote: false + }); + } else { + await this.cloudSave?.synchronize?.(); + } + return this.getStatus({ refresh: true }); + } + + async signIn(value, passwordValue) { + const email = normalizedEmail(value); + const password = normalizedPassword(passwordValue); + if (!email || !password) { + throw new DurableAccountError( + 'invalid_login', + 'Enter the account email and password.' + ); + } + const { data, error } = await this.client.auth.signInWithPassword({ + email, + password + }); + if (error || !data?.user?.id) throw this.normalizeError(error); + const marked = await this.client.auth.updateUser({ + data: { mythical_void_password_ready: true } + }); + if (marked.error) { + throw this.normalizeError( + marked.error, + 'The account opened, but its recovery status could not be confirmed.' + ); + } + const durableUser = marked.data?.user || data.user; + if (this.cloudSave?.adoptAuthenticatedSession) { + await this.cloudSave.adoptAuthenticatedSession(durableUser, { + preferRemote: true + }); + } else { + if (this.cloudSave) this.cloudSave.currentUser = durableUser; + await this.cloudSave?.synchronize?.(); + } + return this.getStatus({ refresh: true }); + } + + async requestPasswordReset(value) { + const email = normalizedEmail(value); + if (!email) { + throw new DurableAccountError('email_invalid', 'Enter a valid email address.'); + } + const redirectTo = `${window.location.origin}/play/?accountRecovery=1`; + const { error } = await this.client.auth.resetPasswordForEmail(email, { + redirectTo + }); + if (error) throw this.normalizeError(error); + return { requested: true }; + } + + clearSharedCreatureCache() { + const gameState = this.cloudSave?.gameState || + (typeof window !== 'undefined' ? window.GameState : null); + gameState?.set?.('sharedGuardianship.projections', []); + gameState?.set?.('sharedGuardianship.lastSyncedAt', 0); + } + + async signOut() { + if (!this.isConfigured()) return { signedOut: true }; + const { error } = await this.client.auth.signOut({ scope: 'local' }); + if (error) throw this.normalizeError(error, 'This device could not sign out.'); + this.clearSharedCreatureCache(); + this.cloudSave?.disable?.(); + if (this.cloudSave) this.cloudSave.currentUser = null; + return { signedOut: true }; + } + + async deleteAccount(passwordValue, confirmationValue) { + const password = normalizedPassword(passwordValue); + if (!password || String(confirmationValue || '').trim() !== 'DELETE') { + throw new DurableAccountError( + 'account_deletion_confirmation_required', + 'Enter your password and type DELETE exactly.' + ); + } + const { data: current, error: currentError } = + await this.client.auth.getUser(); + const email = normalizedEmail(current?.user?.email); + if (currentError || !email || current?.user?.is_anonymous === true) { + throw new DurableAccountError( + 'account_deletion_unavailable', + 'This permanent account could not be verified.' + ); + } + const { error: signInError } = await this.client.auth.signInWithPassword({ + email, + password + }); + if (signInError) { + throw this.normalizeError(signInError, 'The password was not accepted.'); + } + const { error: deleteError } = await this.client.functions.invoke( + 'delete-cloud-identity', + { body: { confirmation: 'DELETE_PERMANENT_ACCOUNT' } } + ); + if (deleteError) { + throw this.normalizeError( + deleteError, + 'The account could not be deleted. Nothing else was changed.' + ); + } + this.clearSharedCreatureCache(); + this.cloudSave?.disable?.(); + if (this.cloudSave) this.cloudSave.currentUser = null; + try { + await this.client.auth.signOut({ scope: 'local' }); + } catch (_) { + // The server has already deleted the identity. + } + return { deleted: true }; + } + + static installRecoveryFlow(options = {}) { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + const url = new URL(window.location.href); + if (url.searchParams.get('accountRecovery') !== '1') return; + if (document.querySelector('[data-durable-account-recovery]')) return; + + const service = new DurableAccountService(options); + const root = document.createElement('div'); + root.className = 'durable-account-recovery'; + root.dataset.durableAccountRecovery = 'true'; + root.setAttribute('role', 'dialog'); + root.setAttribute('aria-modal', 'true'); + root.setAttribute('aria-label', 'Choose a new account password'); + root.innerHTML = ` +
+

ACCOUNT RECOVERY

+

Choose a new password

+

Checking the secure reset link...

+ + +
`; + document.body.append(root); + + const status = root.querySelector('[data-recovery-status]'); + const form = root.querySelector('[data-recovery-form]'); + const inputs = [...root.querySelectorAll('input')]; + const save = root.querySelector('.shared-guardianship-primary'); + const cleanUrl = () => { + const next = new URL(window.location.href); + ['accountRecovery', 'code', 'token_hash', 'type'].forEach(key => next.searchParams.delete(key)); + window.history.replaceState({}, '', `${next.pathname}${next.search}${next.hash}`); + }; + const close = () => { + cleanUrl(); + root.remove(); + }; + root.querySelector('[data-recovery-close]').addEventListener('click', close); + + service.client?.auth?.getSession().then(({ data, error }) => { + cleanUrl(); + if (error || !data?.session?.user?.id) { + status.textContent = 'This reset link is invalid or expired. Request a new one from Shared Guardianship.'; + return; + } + status.textContent = `Use at least ${MIN_PASSWORD_LENGTH} characters.`; + form.hidden = false; + inputs[0]?.focus(); + }).catch(() => { + cleanUrl(); + status.textContent = 'Account recovery is unavailable right now. Request a new reset link later.'; + }); + + save.addEventListener('click', async () => { + if (inputs[0].value !== inputs[1].value) { + status.textContent = 'The two passwords do not match.'; + return; + } + const password = normalizedPassword(inputs[0].value); + if (!password) { + status.textContent = `Use a password of at least ${MIN_PASSWORD_LENGTH} characters.`; + return; + } + save.disabled = true; + status.textContent = 'Saving the new password...'; + try { + const { data, error } = await service.client.auth.updateUser({ + password, + data: { mythical_void_password_ready: true } + }); + if (error) throw service.normalizeError(error); + if (data?.user?.id && service.cloudSave?.adoptAuthenticatedSession) { + await service.cloudSave.adoptAuthenticatedSession(data.user, { + preferRemote: true + }); + } + status.textContent = 'Password updated. Your Sanctuary is ready.'; + form.hidden = true; + } catch (error) { + status.textContent = error.message || 'The password could not be updated.'; + save.disabled = false; + } + }); + } +} + +if (typeof window !== 'undefined') { + window.DurableAccountService = DurableAccountService; +} diff --git a/src/systems/GameState.js b/src/systems/GameState.js index 54e05d60..ad4aa28a 100644 --- a/src/systems/GameState.js +++ b/src/systems/GameState.js @@ -867,6 +867,11 @@ class GameStateManager { } } }, + sharedGuardianship: { + schemaVersion: 1, + projections: [], + lastSyncedAt: null + }, dailyBonus: { lastLoginDate: null, currentStreak: 0, @@ -4261,6 +4266,27 @@ class GameStateManager { pendingReveal: null }; + const sharedGuardianshipSystem = typeof globalThis !== 'undefined' + ? globalThis.SharedGuardianship + : null; + const sharedGuardianship = data.sharedGuardianship && + typeof data.sharedGuardianship === 'object' + ? data.sharedGuardianship + : {}; + data.sharedGuardianship = { + schemaVersion: 1, + projections: Array.isArray(sharedGuardianship.projections) && + typeof sharedGuardianshipSystem?.normalizeProjection === 'function' + ? sharedGuardianship.projections + .map(entry => sharedGuardianshipSystem.normalizeProjection(entry)) + .filter(Boolean) + .slice(0, 1) + : [], + lastSyncedAt: Number.isFinite(sharedGuardianship.lastSyncedAt) + ? Math.max(0, sharedGuardianship.lastSyncedAt) + : null + }; + const seenReconciliations = new Set(); shrine.reconciliationQueue = ( Array.isArray(shrine.reconciliationQueue) diff --git a/src/systems/SharedGuardianshipService.js b/src/systems/SharedGuardianshipService.js new file mode 100644 index 00000000..eb8ee2d9 --- /dev/null +++ b/src/systems/SharedGuardianshipService.js @@ -0,0 +1,509 @@ +import contract from '../config/sharedGuardianship.json'; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const CODE_PATTERN = /^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/; +const TERMINAL_STATUSES = new Set(['committed', 'cancelled', 'expired']); + +function isSharedGuardianshipEnabled() { + if (contract.enabled) return true; + if (typeof window === 'undefined') return false; + const hostname = String(window.location?.hostname || '').toLowerCase(); + if (!['127.0.0.1', 'localhost'].includes(hostname)) return false; + return new URLSearchParams(window.location?.search || '') + .get('testSharedGuardianship') === '1'; +} + +function normalizeCode(value) { + const compact = String(value || '').toUpperCase().replace(/[^0-9A-F]/g, '').slice(0, 12); + if (!/^[0-9A-F]{12}$/.test(compact)) return null; + return `${compact.slice(0,4)}-${compact.slice(4,8)}-${compact.slice(8,12)}`; +} + +function normalizeInvitation(value) { + if (!value || typeof value !== 'object') return null; + const invitationId = String(value.invitationId || '').toLowerCase(); + if (!UUID_PATTERN.test(invitationId) || !['host','guest'].includes(value.role)) return null; + return { + schemaVersion: 1, + invitationId, + role: value.role, + status: String(value.status || ''), + ownParentId: String(value.ownParentId || ''), + peerSignal: value.peerSignal && typeof value.peerSignal === 'object' + ? { + rarity: String(value.peerSignal.rarity || 'common'), + affinity: String(value.peerSignal.affinity || 'unclassified'), + generation: Math.max(1, Number(value.peerSignal.generation) || 1), + stage: String(value.peerSignal.stage || 'adult') + } + : null, + hostConfirmed: value.hostConfirmed === true, + guestConfirmed: value.guestConfirmed === true, + ownNameChoice: contract.safeNames.includes(value.ownNameChoice) ? value.ownNameChoice : null, + peerNameChoice: contract.safeNames.includes(value.peerNameChoice) ? value.peerNameChoice : null, + nameAgreed: value.nameAgreed === true, + expiresAt: value.expiresAt ? new Date(value.expiresAt).toISOString() : null, + operationId: typeof value.operationId === 'string' ? value.operationId : null, + sharedCreatureId: UUID_PATTERN.test(String(value.sharedCreatureId || '')) + ? String(value.sharedCreatureId).toLowerCase() + : null, + sharedRuntimeId: typeof value.sharedRuntimeId === 'string' ? value.sharedRuntimeId : null, + code: normalizeCode(value.code), + terminal: TERMINAL_STATUSES.has(value.status) + }; +} + +function normalizeProjection(value) { + if (!value || typeof value !== 'object') return null; + const sharedCreatureId = String(value.sharedCreatureId || '').toLowerCase(); + if (!UUID_PATTERN.test(sharedCreatureId) || !value.genes || !Number.isInteger(Number(value.revision))) return null; + return { + schemaVersion: 1, + sharedCreatureId, + runtimeId: String(value.runtimeId || ''), + name: String(value.name || 'Shared Signal').slice(0, 20), + genes: structuredCloneSafe(value.genes), + lifecycle: structuredCloneSafe(value.lifecycle || {}), + care: structuredCloneSafe(value.care || {}), + revision: Number(value.revision), + status: String(value.status || 'active'), + guardianCount: Math.max(1, Math.min(2, Number(value.guardianCount) || 1)), + guardianRole: ['host','guest'].includes(value.guardianRole) ? value.guardianRole : null, + guardianLabel: ['Guardian A','Guardian B'].includes(value.guardianLabel) ? value.guardianLabel : null, + notificationsMuted: value.notificationsMuted === true, + history: Array.isArray(value.history) + ? value.history.slice(0,20).map(entry => ({ + kind: String(entry.kind || ''), + summary: String(entry.summary || '').slice(0,160), + guardianLabel: String(entry.guardianLabel || '').slice(0,24), + revision: Number(entry.revision) || 0, + createdAt: entry.createdAt || null + })) + : [], + updatedAt: value.updatedAt || null, + replay: value.replay === true, + rebased: value.rebased === true, + conflict: value.conflict === true + }; +} + +function structuredCloneSafe(value) { + if (typeof structuredClone === 'function') { + try { return structuredClone(value); } catch (_) { /* JSON fallback */ } + } + return JSON.parse(JSON.stringify(value)); +} + +function randomId() { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID().replace(/-/g, ''); + } + if (typeof globalThis.crypto?.getRandomValues === 'function') { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + } + throw new SharedGuardianshipError( + 'secure_random_unavailable', + 'This browser cannot safely record a shared care action.' + ); +} + +export class SharedGuardianshipError extends Error { + constructor(code, message, cause = null) { + super(message, cause ? { cause } : undefined); + this.name = 'SharedGuardianshipError'; + this.code = code; + } +} + +export function getSharedGuardianshipAvailability(cloudSave, accountStatus = null) { + let reason = null; + if (!isSharedGuardianshipEnabled()) reason = 'feature_disabled'; + else if (cloudSave?.isAgeEligible?.() !== true) reason = 'age_restricted'; + else if (cloudSave?.isEnabled?.() !== true) reason = 'cloud_save_required'; + else if (!cloudSave?.isConfigured?.() || typeof cloudSave?.client?.rpc !== 'function') reason = 'service_unavailable'; + else if (accountStatus && !accountStatus.permanent) reason = 'account_required'; + return { available: reason === null, reason, contract }; +} + +export function getSharedGuardianshipEntryAvailability(cloudSave) { + let reason = null; + if (!isSharedGuardianshipEnabled()) reason = 'feature_disabled'; + else if (cloudSave?.isAgeEligible?.() !== true) reason = 'age_restricted'; + else if ( + !cloudSave?.isConfigured?.() || + typeof cloudSave?.client?.auth?.getSession !== 'function' + ) reason = 'service_unavailable'; + return { available: reason === null, reason, contract }; +} + +export class SharedGuardianshipService { + constructor(options = {}) { + this.cloudSave = options.cloudSave || window.CloudSave || null; + this.gameState = options.gameState || window.GameState || null; + this.account = options.account || new window.DurableAccountService({ + client: this.cloudSave?.client, + cloudSave: this.cloudSave + }); + this.pollers = new Map(); + this.pendingCommandKeys = new Map(); + this.attestedUserId = null; + this.pendingCreationKey = null; + } + + getExpectedRevision() { + const revision = Number(this.cloudSave?.remoteRevision); + return Number.isInteger(revision) && revision > 0 ? revision : null; + } + + async getAvailability() { + const accountStatus = await this.account.getStatus(); + return { ...getSharedGuardianshipAvailability(this.cloudSave, accountStatus), accountStatus }; + } + + mapError(error) { + const source = [error?.code,error?.message,error?.details,error?.hint].filter(Boolean).join(' '); + const known = [ + 'shared_guardianship_permanent_identity_required','shared_guardianship_eligibility_required', + 'shared_guardianship_limit_reached','shared_guardianship_invitation_limit','shared_guardianship_invitation_rate_limited', + 'shared_guardianship_join_rate_limited','shared_guardianship_action_rate_limited', + 'shared_guardianship_invitation_unavailable','shared_guardianship_cloud_save_required', + 'shared_guardianship_parent_unavailable','shared_guardianship_parent_changed', + 'shared_guardianship_invitation_not_found','shared_guardianship_invitation_not_confirmable', + 'shared_guardianship_invitation_locked','shared_guardianship_result_not_ready', + 'shared_guardianship_name_invalid','shared_guardianship_access_denied', + 'shared_guardianship_revision_conflict','shared_guardianship_action_invalid', + 'shared_guardianship_request_invalid','save_revision_conflict' + ]; + const code = known.find(entry => source.includes(entry)) || 'shared_guardianship_service_error'; + const messages = { + shared_guardianship_permanent_identity_required: 'Create or sign in to a verified account first.', + shared_guardianship_eligibility_required: 'Review the Shared Guardianship privacy promise first.', + shared_guardianship_limit_reached: 'This account already cares for a shared creature.', + shared_guardianship_invitation_limit: 'Finish or cancel the current private invitation first.', + shared_guardianship_invitation_rate_limited: 'Too many private links were created. Wait an hour and try again.', + shared_guardianship_join_rate_limited: 'Too many code attempts. Wait ten minutes and try again.', + shared_guardianship_action_rate_limited: 'Too many shared actions happened at once. Wait a minute and try again.', + shared_guardianship_invitation_unavailable: 'That private code is unavailable or has expired.', + shared_guardianship_cloud_save_required: 'Sync this Sanctuary before continuing.', + shared_guardianship_parent_unavailable: 'That creature is not ready to contribute to Fusion.', + shared_guardianship_parent_changed: 'The chosen creature changed. Review the pairing again.', + shared_guardianship_invitation_not_found: 'This private invitation is no longer available.', + shared_guardianship_invitation_not_confirmable: 'Both creatures must be paired before consent.', + shared_guardianship_invitation_locked: 'This Fusion has already started.', + shared_guardianship_result_not_ready: 'The shared creature is still forming.', + shared_guardianship_name_invalid: 'Choose one of the protected creature names.', + shared_guardianship_access_denied: 'This shared creature is not available to this account.', + shared_guardianship_revision_conflict: 'The other Sanctuary changed first. Updating this view now.', + shared_guardianship_action_invalid: 'That shared action could not be verified. Refresh and try again.', + shared_guardianship_request_invalid: 'That private request could not be verified. Refresh and try again.', + save_revision_conflict: 'The Sanctuary changed. Sync and review the pairing again.', + shared_guardianship_service_error: 'Shared Guardianship is temporarily unavailable.' + }; + return new SharedGuardianshipError(code, messages[code], error); + } + + async invoke(name, params = {}) { + const { data, error } = await this.cloudSave.client.rpc(name, params); + if (error) throw this.mapError(error); + if (data?.errorCode) { + throw this.mapError({ + code: data.errorCode, + message: data.errorCode, + details: data + }); + } + return data; + } + + async ensureReady() { + const availability = await this.getAvailability(); + if (!availability.available) { + throw new SharedGuardianshipError( + availability.reason, + availability.reason === 'account_required' + ? 'Create or sign in to a verified account first.' + : availability.reason === 'feature_disabled' + ? 'Shared Guardianship is not open in this release yet.' + : 'Shared Guardianship is unavailable for this profile.' + ); + } + if (!this.getExpectedRevision()) await this.cloudSave.synchronize(); + const user = await this.cloudSave.client.auth.getUser(); + if (user.error || !user.data?.user?.id) throw this.mapError(user.error); + if (this.attestedUserId !== user.data.user.id) { + await this.invoke('attest_shared_guardianship_eligibility', { + p_age_band: this.cloudSave.getAgeGroup(), + p_terms_version: contract.termsVersion, + p_privacy_version: contract.privacyVersion + }); + this.attestedUserId = user.data.user.id; + } + return user.data.user; + } + + parentId(parent) { + const readiness = window.FusionConsent?.getFusionCompanionReadiness?.(parent); + if (!parent?.id || !readiness?.willing) { + throw new SharedGuardianshipError('companion_not_ready', 'This creature is not ready to contribute to Fusion.'); + } + return parent.id; + } + + commandKey(scope, prefix, supplied = null) { + if (supplied) return supplied; + if (!this.pendingCommandKeys.has(scope)) { + this.pendingCommandKeys.set(scope, `${prefix}_${randomId()}`); + } + return this.pendingCommandKeys.get(scope); + } + + clearCommandKey(scope, error = null) { + if ( + !error || + error.code === 'shared_guardianship_revision_conflict' || + error.code === 'shared_guardianship_action_invalid' || + error.code === 'shared_guardianship_access_denied' + ) { + this.pendingCommandKeys.delete(scope); + } + } + + async create(parent) { + await this.ensureReady(); + this.pendingCreationKey ||= `invite_${randomId()}`; + const data = await this.invoke('create_shared_guardianship_invitation', { + p_parent_id: this.parentId(parent), + p_expected_revision: this.getExpectedRevision(), + p_idempotency_key: this.pendingCreationKey + }); + const invitation = normalizeInvitation(data); + if (invitation) this.pendingCreationKey = null; + return invitation; + } + + async join(code, parent) { + await this.ensureReady(); + const normalized = normalizeCode(code); + if (!normalized) throw new SharedGuardianshipError('invalid_code', 'Enter the complete private code.'); + const data = await this.invoke('join_shared_guardianship_invitation', { + p_code: normalized, + p_parent_id: this.parentId(parent), + p_expected_revision: this.getExpectedRevision() + }); + return normalizeInvitation(data); + } + + async get(invitationId) { + await this.ensureReady(); + return normalizeInvitation(await this.invoke('get_shared_guardianship_invitation', { p_invitation_id: invitationId })); + } + + async confirm(invitationId) { + await this.ensureReady(); + return normalizeInvitation(await this.invoke('confirm_shared_guardianship_invitation', { p_invitation_id: invitationId })); + } + + async execute(invitationId) { + await this.ensureReady(); + const { data, error } = await this.cloudSave.client.functions.invoke('execute-fusion', { + body: { guardianshipInvitationId: invitationId } + }); + if (error) throw this.mapError(error); + return data; + } + + async chooseName(invitationId, name) { + if (!contract.safeNames.includes(name)) throw new SharedGuardianshipError('shared_guardianship_name_invalid', 'Choose a protected creature name.'); + await this.ensureReady(); + const invitation = normalizeInvitation(await this.invoke('submit_shared_guardianship_name', { + p_invitation_id: invitationId, + p_name: name + })); + if (invitation?.status === 'committed') await this.refreshAll(); + return invitation; + } + + async refreshAll() { + await this.ensureReady(); + const data = await this.invoke('list_shared_guardianship_creatures'); + const projections = Array.isArray(data) ? data.map(normalizeProjection).filter(Boolean) : []; + this.gameState?.set?.('sharedGuardianship.projections', projections); + this.gameState?.set?.('sharedGuardianship.lastSyncedAt', Date.now()); + return projections; + } + + async getProjection(creatureId) { + await this.ensureReady(); + const projection = normalizeProjection(await this.invoke('get_shared_guardianship_projection', { p_creature_id: creatureId })); + if (!projection) throw new SharedGuardianshipError('invalid_projection', 'The shared creature returned an invalid state.'); + const current = this.gameState?.get?.('sharedGuardianship.projections') || []; + const next = [...current.filter(entry => entry.sharedCreatureId !== creatureId), projection]; + this.gameState?.set?.('sharedGuardianship.projections', next); + this.gameState?.set?.('sharedGuardianship.lastSyncedAt', Date.now()); + return projection; + } + + storeProjection(projection) { + if (!projection) return null; + const current = this.gameState?.get?.('sharedGuardianship.projections') || []; + const next = [ + ...current.filter(entry => entry.sharedCreatureId !== projection.sharedCreatureId), + projection + ]; + this.gameState?.set?.('sharedGuardianship.projections', next); + this.gameState?.set?.('sharedGuardianship.lastSyncedAt', Date.now()); + return projection; + } + + commandProjection(data) { + const projection = this.storeProjection(normalizeProjection(data)); + if (!projection) { + throw new SharedGuardianshipError( + 'invalid_projection', + 'The shared creature returned an invalid state.' + ); + } + if (data?.conflict === true) { + const conflict = new SharedGuardianshipError( + 'shared_guardianship_revision_conflict', + 'The other Sanctuary changed first. This view is now up to date.' + ); + conflict.latestProjection = projection; + throw conflict; + } + return projection; + } + + async care(creatureId, action, expectedRevision, idempotencyKey = null) { + await this.ensureReady(); + const scope = `care:${creatureId}:${action}`; + const commandKey = this.commandKey(scope, `care_${action}`, idempotencyKey); + try { + const data = await this.invoke('perform_shared_guardianship_care', { + p_creature_id: creatureId, + p_action: action, + p_idempotency_key: commandKey, + p_expected_revision: expectedRevision + }); + const projection = this.commandProjection(data); + this.clearCommandKey(scope); + return projection; + } catch (error) { + this.clearCommandKey(scope, error); + throw error; + } + } + + async setNotificationsMuted(creatureId, muted, expectedRevision, idempotencyKey = null) { + await this.ensureReady(); + const scope = `notifications:${creatureId}:${muted === true}`; + const commandKey = this.commandKey(scope, 'notice', idempotencyKey); + try { + const data = await this.invoke('set_shared_guardianship_notifications', { + p_creature_id: creatureId, + p_muted: muted === true, + p_idempotency_key: commandKey, + p_expected_revision: expectedRevision + }); + const projection = this.commandProjection(data); + this.clearCommandKey(scope); + return projection; + } catch (error) { + this.clearCommandKey(scope, error); + throw error; + } + } + + async leave(creatureId, expectedRevision, idempotencyKey = null) { + await this.ensureReady(); + const scope = `leave:${creatureId}`; + const commandKey = this.commandKey(scope, 'leave', idempotencyKey); + try { + const result = await this.invoke('leave_shared_guardianship', { + p_creature_id: creatureId, + p_idempotency_key: commandKey, + p_expected_revision: expectedRevision + }); + if (result?.conflict === true) { + const latestProjection = this.storeProjection(normalizeProjection(result)); + const conflict = new SharedGuardianshipError( + 'shared_guardianship_revision_conflict', + 'The shared creature changed. Review the latest state before leaving.' + ); + conflict.latestProjection = latestProjection; + throw conflict; + } + await this.refreshAll(); + this.clearCommandKey(scope); + return result; + } catch (error) { + this.clearCommandKey(scope, error); + throw error; + } + } + + async cancel(invitationId) { + await this.ensureReady(); + return normalizeInvitation(await this.invoke('cancel_shared_guardianship_invitation', { p_invitation_id: invitationId })); + } + + watch(creatureId, callback, intervalMs = 3000) { + this.stopWatching(creatureId); + let lastRevision = 0; + let inFlight = false; + let stopped = false; + const poll = async () => { + if (stopped || inFlight) return; + inFlight = true; + try { + const projection = await this.getProjection(creatureId); + if (!stopped && projection.revision !== lastRevision) { + lastRevision = projection.revision; + callback(projection, null); + } + } catch (error) { + if (!stopped) callback(null, error); + } finally { + inFlight = false; + } + }; + poll(); + const timer = window.setInterval(poll, Math.max(2000, intervalMs)); + this.pollers.set(creatureId, { + timer, + stop: () => { stopped = true; } + }); + return () => this.stopWatching(creatureId); + } + + stopWatching(creatureId) { + const poller = this.pollers.get(creatureId); + poller?.stop?.(); + if (poller?.timer) window.clearInterval(poller.timer); + this.pollers.delete(creatureId); + } + + destroy() { + for (const poller of this.pollers.values()) { + poller?.stop?.(); + if (poller?.timer) window.clearInterval(poller.timer); + } + this.pollers.clear(); + this.pendingCommandKeys.clear(); + } +} + +if (typeof window !== 'undefined') { + window.SharedGuardianship = { + contract, + normalizeCode, + normalizeInvitation, + normalizeProjection, + isEnabled: isSharedGuardianshipEnabled, + getSharedGuardianshipEntryAvailability, + getSharedGuardianshipAvailability + }; + window.SharedGuardianshipService = SharedGuardianshipService; +} diff --git a/src/ui/SharedCreatureCareModal.js b/src/ui/SharedCreatureCareModal.js new file mode 100644 index 00000000..c6b58b2d --- /dev/null +++ b/src/ui/SharedCreatureCareModal.js @@ -0,0 +1,376 @@ +function element(tagName, className, text = null) { + const node = document.createElement(tagName); + node.className = className; + if (text !== null) node.textContent = text; + if (className === 'shared-guardianship-notice') { + node.setAttribute('role', 'status'); + node.setAttribute('aria-live', 'polite'); + } + return node; +} + +function focusableElements(root) { + return [...root.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' + )].filter(node => !node.hidden && node.getAttribute('aria-hidden') !== 'true'); +} + +function button(className, text, onClick) { + const node = element('button', className, text); + node.type = 'button'; + node.addEventListener('click', onClick); + return node; +} + +export default class SharedCreatureCareModal { + constructor(scene, options = {}) { + this.scene = scene; + this.service = options.service; + this.projection = options.projection; + this.onClose = options.onClose; + this.onUpdate = options.onUpdate; + this.onAccessRevoked = options.onAccessRevoked; + this.root = null; + this.domElement = null; + this.stopWatching = null; + this.busy = false; + this.confirmLeave = false; + this.manageAccess = false; + this.confirmDeleteAccount = false; + this.deletePassword = ''; + this.deleteConfirmation = ''; + this.keyboardHandler = null; + this.previousFocus = null; + } + + show() { + if (this.root || !this.projection) return false; + const { width, height } = this.scene.scale; + this.previousFocus = document.activeElement; + this.root = element('div', 'shared-guardianship-modal'); + this.root.style.width = `${width}px`; + this.root.style.height = `${height}px`; + this.root.setAttribute('role', 'dialog'); + this.root.setAttribute('aria-modal', 'true'); + this.root.setAttribute('aria-label', `${this.projection.name} shared habitat`); + this.domElement = this.scene.add.dom(width / 2, height / 2, this.root) + .setOrigin(0.5) + .setScrollFactor(0) + .setDepth(17920); + const container = this.domElement.node?.parentElement; + if (container) container.style.zIndex = '17920'; + this.keyboardHandler = event => { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + return; + } + if (event.key !== 'Tab' || !this.root) return; + const focusable = focusableElements(this.root); + event.preventDefault(); + event.stopImmediatePropagation(); + if (focusable.length === 0) { + this.root.focus(); + return; + } + const activeIndex = focusable.indexOf(document.activeElement); + const nextIndex = event.shiftKey + ? (activeIndex <= 0 ? focusable.length - 1 : activeIndex - 1) + : (activeIndex < 0 || activeIndex === focusable.length - 1 ? 0 : activeIndex + 1); + focusable[nextIndex].focus(); + }; + this.root.addEventListener('keydown', this.keyboardHandler, true); + requestAnimationFrame(() => { + this.root?.classList.add('is-visible'); + focusableElements(this.root)[0]?.focus({ preventScroll: true }); + }); + this.render(); + this.stopWatching = this.service.watch( + this.projection.sharedCreatureId, + (projection) => { + if (!projection || this.busy) return; + this.projection = projection; + this.onUpdate?.(projection); + this.render('Together'); + } + ); + return true; + } + + render(status = '') { + if (!this.root) return; + this.root.replaceChildren(); + const shell = element('section', 'shared-guardianship-shell'); + const header = element('header', 'shared-guardianship-header'); + const heading = element('div', 'shared-guardianship-heading'); + const sharedState = this.projection.guardianCount > 1 + ? 'TOGETHER' + : 'ONE GUARDIAN'; + heading.append( + element('p', 'shared-guardianship-eyebrow', `SHARED HABITAT // ${sharedState}`), + element('h2', 'shared-guardianship-title', this.projection.name) + ); + const close = button('shared-guardianship-close', 'CLOSE', () => this.close()); + close.setAttribute('aria-label', 'Close shared creature habitat'); + header.append(heading, close); + const body = element('div', 'shared-guardianship-body'); + if (this.manageAccess) { + body.append( + element( + 'p', + 'shared-guardianship-helper', + 'Manage this device or permanently remove your account. Shared creature continuity is protected when access changes.' + ), + this.accountAccessPanel() + ); + shell.append(header, body); + this.root.append(shell); + return; + } + body.append( + element('p', 'shared-guardianship-helper', 'This is the same creature in both Sanctuaries. Care from either guardian changes this one shared life.'), + this.careSummary() + ); + const actions = element('div', 'shared-guardianship-care-grid'); + actions.append( + button('shared-guardianship-care', 'TEND\nComfort +8', () => this.care('tend')), + button('shared-guardianship-care', 'PLAY\nCuriosity +8', () => this.care('play')), + button('shared-guardianship-care', 'REST\nEnergy +10', () => this.care('rest')) + ); + body.append(actions); + if (status) body.append(element('p', 'shared-guardianship-notice', status)); + const history = element('section', 'shared-guardianship-history'); + history.append(element('h3', '', 'Recent moments')); + (this.projection.history || []).slice(0, 6).forEach(entry => { + history.append(element('p', '', entry.summary)); + }); + body.append(history); + const notificationSetting = element('label', 'shared-guardianship-notification-setting'); + const notificationToggle = document.createElement('input'); + notificationToggle.type = 'checkbox'; + notificationToggle.checked = this.projection.notificationsMuted === true; + notificationToggle.addEventListener('change', () => { + this.setNotificationsMuted(notificationToggle.checked); + }); + notificationSetting.append( + notificationToggle, + element('span', '', 'Mute optional shared activity notices') + ); + body.append(notificationSetting); + body.append(button('shared-guardianship-text-button', 'Account & privacy', () => { + this.manageAccess = true; + this.confirmLeave = false; + this.render(); + })); + if (this.confirmLeave) { + const leave = element('section', 'shared-guardianship-consent'); + leave.append( + element('span', '', ''), + element('span', '', this.projection.guardianCount > 1 + ? 'Leave Shared Guardianship? The other guardian keeps the creature. You immediately lose access, but the creature and its history remain safe.' + : 'Leave Shared Guardianship? You immediately lose access. With no guardian remaining, the creature rests safely in the archive; it is not sold, transferred or erased.') + ); + body.append( + leave, + button('shared-guardianship-secondary', 'CONFIRM LEAVE', () => this.leave()), + button('shared-guardianship-text-button', 'Keep caring together', () => { + this.confirmLeave = false; + this.render(); + }) + ); + } else { + body.append(button('shared-guardianship-text-button', 'Leave Shared Guardianship', () => { + this.confirmLeave = true; + this.render(); + })); + } + shell.append(header, body); + this.root.append(shell); + } + + accountAccessPanel() { + const access = element('section', 'shared-guardianship-access'); + access.append(element('h3', '', 'Account access')); + if (this.confirmDeleteAccount) { + const password = document.createElement('input'); + password.className = 'shared-guardianship-input'; + password.type = 'password'; + password.autocomplete = 'current-password'; + password.placeholder = 'Account password'; + password.setAttribute('aria-label', 'Account password'); + password.value = this.deletePassword; + password.addEventListener('input', () => { + this.deletePassword = password.value; + }); + const confirmation = document.createElement('input'); + confirmation.className = 'shared-guardianship-input'; + confirmation.type = 'text'; + confirmation.autocomplete = 'off'; + confirmation.placeholder = 'Type DELETE'; + confirmation.setAttribute('aria-label', 'Type DELETE to confirm'); + confirmation.value = this.deleteConfirmation; + confirmation.addEventListener('input', () => { + this.deleteConfirmation = confirmation.value; + }); + access.append( + element('p', '', 'Delete this account permanently? Shared access ends immediately. Your solo game stays on this device. A surviving guardian keeps the shared creature; with no guardian it rests in the archive.'), + password, + confirmation, + button('shared-guardianship-secondary', 'DELETE ACCOUNT PERMANENTLY', () => this.deleteAccount()), + button('shared-guardianship-text-button', 'Cancel account deletion', () => { + this.confirmDeleteAccount = false; + this.deletePassword = ''; + this.deleteConfirmation = ''; + this.render(); + }) + ); + } else { + access.append( + element('p', '', 'Signing out removes this shared creature from this device. Your solo local game remains here, and the shared creature returns after you sign in again.'), + button('shared-guardianship-secondary', 'SIGN OUT OF THIS DEVICE', () => this.signOut()), + button('shared-guardianship-text-button', 'Delete account and shared access', () => { + this.confirmDeleteAccount = true; + this.render(); + }), + button('shared-guardianship-text-button', 'Back to shared habitat', () => { + this.manageAccess = false; + this.render(); + }) + ); + } + return access; + } + + careSummary() { + const summary = element('div', 'shared-guardianship-care-summary'); + const care = this.projection.care || {}; + [['COMFORT', care.comfort], ['CURIOSITY', care.curiosity], ['ENERGY', care.energy]].forEach(([label, value]) => { + const stat = element('div', 'shared-guardianship-stat'); + stat.append(element('span', '', label), element('strong', '', `${Math.max(0, Math.min(100, Number(value) || 0))}`)); + summary.append(stat); + }); + return summary; + } + + async care(action) { + if (this.busy) return; + this.busy = true; + this.render('Saving this moment...'); + try { + const projection = await this.service.care( + this.projection.sharedCreatureId, + action, + this.projection.revision + ); + this.projection = projection; + this.onUpdate?.(projection); + this.render('The same change is now visible in both Sanctuaries.'); + } catch (error) { + if (error.code === 'shared_guardianship_revision_conflict') { + this.projection = error.latestProjection || + await this.service.getProjection(this.projection.sharedCreatureId); + this.onUpdate?.(this.projection); + } + this.render(error.message || 'Connection paused. The last safe view remains here.'); + } finally { + this.busy = false; + } + } + + async leave() { + if (this.busy) return; + this.busy = true; + try { + await this.service.leave( + this.projection.sharedCreatureId, + this.projection.revision + ); + this.close(); + } catch (error) { + this.confirmLeave = false; + if (error.code === 'shared_guardianship_revision_conflict') { + this.projection = error.latestProjection || this.projection; + this.onUpdate?.(this.projection); + } + this.render(error.message || 'Leaving is unavailable right now.'); + } finally { + this.busy = false; + } + } + + async setNotificationsMuted(muted) { + if (this.busy) return; + this.busy = true; + this.render('Saving notice preference...'); + try { + const projection = await this.service.setNotificationsMuted( + this.projection.sharedCreatureId, + muted, + this.projection.revision + ); + this.projection = projection; + this.onUpdate?.(projection); + this.render(muted ? 'Optional notices muted.' : 'Optional notices enabled.'); + } catch (error) { + if (error.code === 'shared_guardianship_revision_conflict') { + this.projection = error.latestProjection || this.projection; + this.onUpdate?.(this.projection); + } + this.render(error.message || 'Notice preference could not be changed.'); + } finally { + this.busy = false; + } + } + + async signOut() { + if (this.busy) return; + this.busy = true; + this.render('Signing out of this device...'); + try { + await this.service.account.signOut(); + this.onAccessRevoked?.(); + this.close(); + } catch (error) { + this.render(error.message || 'This device could not sign out.'); + } finally { + this.busy = false; + } + } + + async deleteAccount() { + if (this.busy) return; + this.busy = true; + this.render('Deleting this account and ending shared access...'); + try { + await this.service.account.deleteAccount( + this.deletePassword, + this.deleteConfirmation + ); + this.onAccessRevoked?.(); + this.close(); + } catch (error) { + this.confirmDeleteAccount = true; + this.render(error.message || 'The account could not be deleted.'); + } finally { + this.busy = false; + } + } + + close() { + this.stopWatching?.(); + this.stopWatching = null; + if (this.keyboardHandler) this.root.removeEventListener('keydown', this.keyboardHandler, true); + this.keyboardHandler = null; + this.domElement?.destroy?.(); + if (this.previousFocus?.isConnected) { + this.previousFocus.focus({ preventScroll: true }); + } + this.previousFocus = null; + this.root = null; + this.onClose?.(); + } + + destroy() { + this.close(); + } +} diff --git a/src/ui/SharedGuardianshipModal.js b/src/ui/SharedGuardianshipModal.js new file mode 100644 index 00000000..66bd01c2 --- /dev/null +++ b/src/ui/SharedGuardianshipModal.js @@ -0,0 +1,697 @@ +function element(tagName, className, text = null) { + const node = document.createElement(tagName); + node.className = className; + if (text !== null) node.textContent = text; + if (className === 'shared-guardianship-notice') { + node.setAttribute('role', 'status'); + node.setAttribute('aria-live', 'polite'); + } + return node; +} + +function focusableElements(root) { + return [...root.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' + )].filter(node => !node.hidden && node.getAttribute('aria-hidden') !== 'true'); +} + +function button(className, text, onClick) { + const node = element('button', className, text); + node.type = 'button'; + node.addEventListener('click', onClick); + return node; +} + +export default class SharedGuardianshipModal { + constructor(scene, options = {}) { + this.scene = scene; + this.gameState = options.gameState || window.GameState; + this.cloudSave = options.cloudSave || window.CloudSave; + this.account = options.account || new window.DurableAccountService({ + client: this.cloudSave?.client, + cloudSave: this.cloudSave + }); + this.service = options.service || new window.SharedGuardianshipService({ + cloudSave: this.cloudSave, + gameState: this.gameState, + account: this.account + }); + this.previewAccess = options.previewAccess === true; + this.root = null; + this.body = null; + this.domElement = null; + this.parents = []; + this.selectedParentId = null; + this.mode = 'create'; + this.accountMode = 'create'; + this.accountStep = 'email'; + this.pendingEmail = ''; + this.invitation = null; + this.busy = false; + this.pollTimer = null; + this.pollInFlight = false; + this.pollGeneration = 0; + this.keyboardHandler = null; + this.previousFocus = null; + this.consentChecked = false; + this.policyChecked = false; + this.cloudConsentChecked = false; + this.onClose = null; + this.onComplete = null; + } + + show({ parents = [], onClose, onComplete } = {}) { + if (this.root || typeof document === 'undefined') return false; + this.parents = parents.filter(parent => ( + window.FusionConsent?.getFusionCompanionReadiness?.(parent)?.willing + )); + this.selectedParentId = this.parents[0]?.id || null; + this.onClose = onClose; + this.onComplete = onComplete; + this.previousFocus = document.activeElement; + const { width, height } = this.scene.scale; + this.root = element('div', 'shared-guardianship-modal'); + this.root.style.width = `${width}px`; + this.root.style.height = `${height}px`; + this.root.setAttribute('role', 'dialog'); + this.root.setAttribute('aria-modal', 'true'); + this.root.setAttribute('aria-label', 'Shared Guardianship'); + this.root.dataset.testid = 'shared-guardianship-modal'; + const shell = element('section', 'shared-guardianship-shell'); + const header = element('header', 'shared-guardianship-header'); + const heading = element('div', 'shared-guardianship-heading'); + heading.append( + element('p', 'shared-guardianship-eyebrow', 'FUSION POD // PRIVATE CONNECTION'), + element('h2', 'shared-guardianship-title', 'SHARED GUARDIANSHIP') + ); + const close = button('shared-guardianship-close', 'CLOSE', () => this.close()); + close.setAttribute('aria-label', 'Close Shared Guardianship'); + header.append(heading, close); + this.body = element('div', 'shared-guardianship-body'); + shell.append(header, this.body); + this.root.append(shell); + this.root.addEventListener('pointerdown', event => event.stopPropagation()); + this.keyboardHandler = event => { + if (event.key === 'Escape') { + event.preventDefault(); + this.close(); + return; + } + if (event.key !== 'Tab' || !this.root) return; + const focusable = focusableElements(this.root); + event.preventDefault(); + event.stopImmediatePropagation(); + if (focusable.length === 0) { + this.root.focus(); + return; + } + const activeIndex = focusable.indexOf(document.activeElement); + const nextIndex = event.shiftKey + ? (activeIndex <= 0 ? focusable.length - 1 : activeIndex - 1) + : (activeIndex < 0 || activeIndex === focusable.length - 1 ? 0 : activeIndex + 1); + focusable[nextIndex].focus(); + }; + this.root.addEventListener('keydown', this.keyboardHandler, true); + this.domElement = this.scene.add.dom(width / 2, height / 2, this.root) + .setOrigin(0.5) + .setScrollFactor(0) + .setDepth(17900); + const container = this.domElement.node?.parentElement; + if (container) container.style.zIndex = '17900'; + requestAnimationFrame(() => { + this.root?.classList.add('is-visible'); + focusableElements(this.root)[0]?.focus({ preventScroll: true }); + }); + this.start(); + return true; + } + + clear() { + this.body?.replaceChildren(); + } + + async start() { + this.renderBusy('CHECKING YOUR SANCTUARY'); + try { + const entry = this.previewAccess + ? { available: true } + : window.SharedGuardianship + ?.getSharedGuardianshipEntryAvailability?.(this.cloudSave); + if (!entry?.available) { + throw new Error(entry?.reason === 'age_restricted' + ? 'Shared Guardianship is available only to profiles confirmed as 16 or older.' + : 'Shared Guardianship is temporarily unavailable.'); + } + if (!this.previewAccess && this.cloudSave?.isEnabled?.() !== true) { + this.renderCloudGate(); + return; + } + const status = await this.account.getStatus({ refresh: true }); + if (!status.permanent) { + if (status.identityVerified && !status.passwordReady) { + this.accountMode = 'create'; + this.accountStep = 'password'; + } + this.renderAccountGate(); + } + else this.renderHome(); + } catch (error) { + this.renderError(error); + } + } + + renderBusy(label) { + this.clear(); + const busy = element('div', 'shared-guardianship-busy'); + busy.setAttribute('role', 'status'); + busy.setAttribute('aria-live', 'polite'); + const pulse = element('span', 'shared-guardianship-pulse'); + pulse.setAttribute('aria-hidden', 'true'); + busy.append(pulse, element('p', '', label)); + this.body.append(busy); + } + + renderCloudGate(notice = '') { + this.stopPolling(); + this.clear(); + const panel = element('section', 'shared-guardianship-intro'); + panel.dataset.testid = 'shared-guardianship-cloud-gate'; + panel.append( + element('h3', '', 'Keep this Sanctuary available on both devices'), + element('p', '', 'Shared Guardianship needs private Cloud Save so one creature can stay synchronized for the same two verified people. Solo play still works without it.'), + element('p', 'shared-guardianship-boundary', '16+ ONLY // PRIVATE SAVE // NO CHAT') + ); + const consent = element('label', 'shared-guardianship-consent'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = this.cloudConsentChecked; + const copy = element('span', '', 'Enable private Cloud Save for game progress and Shared Guardianship. The other guardian never sees account details or unrelated save data.'); + const links = element('span', 'shared-guardianship-policy-links'); + const privacy = document.createElement('a'); + privacy.href = '/privacy/'; + privacy.target = '_blank'; + privacy.rel = 'noopener'; + privacy.textContent = 'Privacy'; + copy.append(document.createElement('br')); + links.append(privacy); + copy.append(links); + consent.append(checkbox, copy); + const enable = button( + 'shared-guardianship-primary', + 'ENABLE PRIVATE SAVE & CONTINUE', + () => this.enableCloudSave() + ); + enable.dataset.testid = 'shared-guardianship-enable-cloud'; + enable.disabled = !this.cloudConsentChecked; + checkbox.addEventListener('change', () => { + this.cloudConsentChecked = checkbox.checked; + enable.disabled = !this.cloudConsentChecked; + }); + this.body.append(panel, consent, enable); + if (notice) { + this.body.append(element('p', 'shared-guardianship-notice', notice)); + } + } + + async enableCloudSave() { + if (this.busy || !this.cloudConsentChecked) return; + this.busy = true; + this.renderBusy('SECURING THIS SANCTUARY'); + try { + await this.cloudSave.enable({ + consentConfirmed: true, + policyVersion: window.SharedGuardianship.contract.privacyVersion + }); + await this.start(); + } catch (error) { + this.renderCloudGate( + error?.message || 'Private Cloud Save could not be enabled. Your local game is safe.' + ); + } finally { + this.busy = false; + } + } + + renderAccountGate(notice = '') { + this.stopPolling(); + this.clear(); + const intro = element('section', 'shared-guardianship-intro'); + intro.dataset.testid = 'shared-guardianship-account-gate'; + intro.append( + element('h3', '', 'Keep one shared creature safe on both devices'), + element('p', '', 'Shared Guardianship needs a verified account so your creature can return to the same two people after a device is changed or lost.'), + element('p', 'shared-guardianship-boundary', '16+ ONLY // NO CHAT // NO PLAYER SEARCH') + ); + const tabs = element('div', 'shared-guardianship-tabs'); + tabs.append( + button(`shared-guardianship-tab${this.accountMode === 'create' ? ' is-active' : ''}`, 'CREATE ACCOUNT', () => { + this.accountMode = 'create'; + this.accountStep = 'email'; + this.renderAccountGate(); + }), + button(`shared-guardianship-tab${this.accountMode === 'signin' ? ' is-active' : ''}`, 'SIGN IN', () => { + this.accountMode = 'signin'; + this.renderAccountGate(); + }) + ); + const form = element('div', 'shared-guardianship-form'); + if (this.accountMode === 'signin') { + const email = this.input('email', 'Email', 'email'); + const password = this.input('password', 'Password', 'current-password'); + form.append( + email, + password, + button('shared-guardianship-primary', 'SIGN IN', () => this.signIn(email.value, password.value)), + button('shared-guardianship-text-button', 'Forgot password', () => this.resetPassword(email.value)) + ); + } else if (this.accountStep === 'email') { + const email = this.input('email', 'Email', 'email'); + form.append( + element('p', 'shared-guardianship-helper', 'Your email is used only for account access. The other guardian never sees it.'), + email, + button('shared-guardianship-primary', 'SEND VERIFICATION', () => this.beginUpgrade(email.value)) + ); + } else if (this.accountStep === 'verify') { + const code = this.input('text', 'Email code (if shown)', 'one-time-code'); + code.inputMode = 'numeric'; + form.append( + element('p', 'shared-guardianship-helper', 'Open the verification email. Return here after using its link, or enter the code shown in the message.'), + code, + button('shared-guardianship-primary', 'VERIFY CODE', () => this.verifyCode(code.value)), + button('shared-guardianship-secondary', 'I USED THE EMAIL LINK', () => this.checkVerified()) + ); + } else { + const password = this.input('password', 'Choose a password', 'new-password'); + form.append( + element('p', 'shared-guardianship-helper', 'Use at least 10 characters. Your current save stays attached to this same account.'), + password, + button('shared-guardianship-primary', 'FINISH ACCOUNT', () => this.finishUpgrade(password.value)) + ); + } + if (notice) form.append(element('p', 'shared-guardianship-notice', notice)); + this.body.append(intro, tabs, form); + form.querySelector('input')?.focus({ preventScroll: true }); + } + + input(type, placeholder, autocomplete) { + const input = element('input', 'shared-guardianship-input'); + input.type = type; + input.placeholder = placeholder; + input.autocomplete = autocomplete; + input.setAttribute('aria-label', placeholder); + return input; + } + + async beginUpgrade(email) { + if (this.busy) return; + this.busy = true; + this.renderBusy('SENDING VERIFICATION'); + try { + const result = await this.account.beginUpgrade(email); + this.pendingEmail = String(email).trim().toLowerCase(); + this.accountStep = result?.passwordRequired ? 'password' : 'verify'; + this.renderAccountGate(result?.passwordRequired + ? 'Email verified. Choose a password to finish.' + : 'Verification sent. Your game remains playable while you check it.'); + } catch (error) { + this.accountStep = 'email'; + this.renderAccountGate(error.message); + } finally { + this.busy = false; + } + } + + async verifyCode(code) { + if (this.busy) return; + this.busy = true; + this.renderBusy('VERIFYING ACCOUNT'); + try { + await this.account.verifyEmailCode(this.pendingEmail, code); + this.accountStep = 'password'; + this.renderAccountGate('Email verified. Choose a password to finish.'); + } catch (error) { + this.accountStep = 'verify'; + this.renderAccountGate(error.message); + } finally { + this.busy = false; + } + } + + async checkVerified() { + try { + const status = await this.account.getStatus({ refresh: true }); + if (!status.identityVerified) { + this.renderAccountGate('The verification has not reached this browser yet. Try the email link again.'); + return; + } + this.accountStep = 'password'; + this.renderAccountGate('Email verified. Choose a password to finish.'); + } catch (error) { + this.renderAccountGate(error.message); + } + } + + async finishUpgrade(password) { + if (this.busy) return; + this.busy = true; + this.renderBusy('SECURING YOUR ACCOUNT'); + try { + await this.account.finishUpgrade(password); + this.renderHome('Account ready. Your progress can now return on another device.'); + } catch (error) { + this.accountStep = 'password'; + this.renderAccountGate(error.message); + } finally { + this.busy = false; + } + } + + async signIn(email, password) { + if (this.busy) return; + this.busy = true; + this.renderBusy('OPENING YOUR ACCOUNT'); + try { + await this.account.signIn(email, password); + this.renderHome('Signed in. Your shared creature will use this account.'); + } catch (error) { + this.renderAccountGate(error.message); + } finally { + this.busy = false; + } + } + + async resetPassword(email) { + try { + await this.account.requestPasswordReset(email); + this.renderAccountGate('Password reset sent if that account exists.'); + } catch (error) { + this.renderAccountGate(error.message); + } + } + + renderHome(notice = '') { + this.stopPolling(); + this.clear(); + this.invitation = null; + const intro = element('section', 'shared-guardianship-intro'); + intro.dataset.testid = 'shared-guardianship-home'; + intro.append( + element('h3', '', 'One creature. Two Sanctuaries.'), + element('p', '', 'Each person contributes one willing adult creature. The Fusion Pod creates one child that remains visible and cared for on both devices.'), + element('p', 'shared-guardianship-boundary', 'PRIVATE CODE // NO CHAT // EITHER PERSON CAN PLAY ALONE') + ); + const tabs = element('div', 'shared-guardianship-tabs'); + tabs.append( + button(`shared-guardianship-tab${this.mode === 'create' ? ' is-active' : ''}`, 'CREATE PRIVATE CODE', () => { this.mode = 'create'; this.renderHome(); }), + button(`shared-guardianship-tab${this.mode === 'join' ? ' is-active' : ''}`, 'ENTER PRIVATE CODE', () => { this.mode = 'join'; this.renderHome(); }) + ); + const parentList = element('div', 'shared-guardianship-parent-list'); + if (this.parents.length === 0) { + parentList.append( + element( + 'p', + 'shared-guardianship-empty', + 'Raise one creature to adulthood and confirm that it is willing to contribute to Fusion. Then return here.' + ) + ); + } + this.parents.forEach(parent => { + const choice = button(`shared-guardianship-parent${parent.id === this.selectedParentId ? ' is-selected' : ''}`, '', () => { + this.selectedParentId = parent.id; + this.renderHome(); + }); + choice.append( + element('strong', '', parent.name || 'Companion'), + element('span', '', `${String(parent.rarity || parent.genes?.rarity || 'common').toUpperCase()} // ${String(parent.lifecycle?.stage || 'adult').toUpperCase()}`) + ); + parentList.append(choice); + }); + const action = element('div', 'shared-guardianship-form'); + const policy = element('label', 'shared-guardianship-consent'); + const policyCheckbox = document.createElement('input'); + policyCheckbox.type = 'checkbox'; + policyCheckbox.checked = this.policyChecked; + const policyText = element('span', '', 'I am 16 or older and have reviewed how this private shared feature uses account and creature data.'); + const policyLinks = element('span', 'shared-guardianship-policy-links'); + const privacyLink = document.createElement('a'); + privacyLink.href = '/privacy/'; + privacyLink.target = '_blank'; + privacyLink.rel = 'noopener'; + privacyLink.textContent = 'Privacy'; + const termsLink = document.createElement('a'); + termsLink.href = '/terms/'; + termsLink.target = '_blank'; + termsLink.rel = 'noopener'; + termsLink.textContent = 'Terms'; + policyLinks.append(privacyLink, document.createTextNode(' · '), termsLink); + policyText.append(document.createElement('br'), policyLinks); + policy.append(policyCheckbox, policyText); + let primaryAction; + if (this.mode === 'join') { + const code = this.input('text', 'XXXX-XXXX-XXXX', 'off'); + code.maxLength = 14; + code.addEventListener('input', () => { + const normalized = window.SharedGuardianship?.normalizeCode?.(code.value); + if (normalized) code.value = normalized; + }); + primaryAction = button('shared-guardianship-primary', 'JOIN PRIVATE LINK', () => this.join(code.value)); + action.append(code, primaryAction); + } else { + primaryAction = button('shared-guardianship-primary', 'CREATE PRIVATE LINK', () => this.create()); + action.append(primaryAction); + } + primaryAction.disabled = !this.policyChecked || !this.selectedParent(); + policyCheckbox.addEventListener('change', () => { + this.policyChecked = policyCheckbox.checked; + primaryAction.disabled = !this.policyChecked || !this.selectedParent(); + }); + if (notice) action.append(element('p', 'shared-guardianship-notice', notice)); + this.body.append(intro, tabs, element('p', 'shared-guardianship-label', 'CHOOSE YOUR CONTRIBUTING CREATURE'), parentList, policy, action); + } + + selectedParent() { + return this.parents.find(parent => parent.id === this.selectedParentId) || null; + } + + async create() { + await this.run('CREATING PRIVATE LINK', async () => { + this.invitation = await this.service.create(this.selectedParent()); + this.renderInvitation(); + }, () => this.renderHome()); + } + + async join(code) { + await this.run('JOINING PRIVATE LINK', async () => { + this.invitation = await this.service.join(code, this.selectedParent()); + this.renderInvitation(); + }, () => this.renderHome()); + } + + renderInvitation(notice = '') { + this.clear(); + const invitation = this.invitation; + if (!invitation) return this.renderHome(); + if (invitation.status === 'committed') return this.renderCommitted(); + const header = element('section', 'shared-guardianship-intro'); + header.append( + element('h3', '', invitation.peerSignal ? 'Both creatures are here' : 'Waiting for someone you know'), + element('p', '', invitation.peerSignal + ? 'Review the pairing. Nothing is created until both people agree.' + : 'Share this one-time code privately. It expires automatically.'), + ); + if (invitation.code) header.append(element('strong', 'shared-guardianship-code', invitation.code)); + if (invitation.peerSignal) { + header.append(element('p', 'shared-guardianship-peer', `${invitation.peerSignal.affinity.toUpperCase()} // ${invitation.peerSignal.rarity.toUpperCase()} // GEN ${invitation.peerSignal.generation}`)); + } + this.body.append(header); + if (['staged','executing','ready'].includes(invitation.status) && invitation.hostConfirmed && invitation.guestConfirmed) { + if (invitation.status === 'staged') this.renderNaming(notice); + else this.renderFormation(); + return; + } + if (invitation.peerSignal) { + const consent = element('label', 'shared-guardianship-consent'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = this.consentChecked; + checkbox.addEventListener('change', () => { + this.consentChecked = checkbox.checked; + confirm.disabled = !this.consentChecked; + }); + consent.append(checkbox, element('span', '', 'I understand this creates one shared creature. Both guardians can always see and care for it, and either guardian may leave without deleting it.')); + const confirm = button('shared-guardianship-primary', this.ownConfirmed() ? 'WAITING FOR OTHER GUARDIAN' : 'I AGREE TO CREATE ONE SHARED CREATURE', () => this.confirm()); + confirm.disabled = this.ownConfirmed() || !this.consentChecked; + this.body.append(consent, confirm); + } + if (notice) this.body.append(element('p', 'shared-guardianship-notice', notice)); + this.body.append(button('shared-guardianship-text-button', 'Cancel private link', () => this.cancel())); + this.startPolling(); + } + + ownConfirmed() { + return this.invitation?.role === 'host' ? this.invitation.hostConfirmed : this.invitation?.guestConfirmed; + } + + async confirm() { + await this.run('RECORDING YOUR AGREEMENT', async () => { + this.invitation = await this.service.confirm(this.invitation.invitationId); + this.consentChecked = false; + if (this.invitation.status === 'ready') await this.formCreature(); + else this.renderInvitation(); + }, () => this.renderInvitation()); + } + + renderFormation() { + this.stopPolling(); + const panel = element('div', 'shared-guardianship-busy'); + panel.append(element('span', 'shared-guardianship-pulse'), element('p', '', 'ONE LIVING SIGNAL IS FORMING')); + this.body.append(panel); + this.formCreature(); + } + + async formCreature() { + try { + await this.service.execute(this.invitation.invitationId); + this.invitation = await this.service.get(this.invitation.invitationId); + this.renderInvitation(); + } catch (error) { + this.renderInvitation(error.message); + } + } + + renderNaming(notice = '') { + this.stopPolling(); + const section = element('section', 'shared-guardianship-naming'); + section.append( + element('h3', '', 'Choose one name together'), + element('p', '', 'Select from the protected list. The name is set when both guardians choose the same one.') + ); + const names = element('div', 'shared-guardianship-name-grid'); + window.SharedGuardianship.contract.safeNames.forEach(name => { + names.append(button(`shared-guardianship-name${this.invitation.ownNameChoice === name ? ' is-selected' : ''}`, name, () => this.chooseName(name))); + }); + section.append(names); + if (this.invitation.peerNameChoice && !this.invitation.nameAgreed) { + section.append(element('p', 'shared-guardianship-notice', `The other guardian chose ${this.invitation.peerNameChoice}. Choose the same name or wait for them to change it.`)); + } + if (notice) section.append(element('p', 'shared-guardianship-notice', notice)); + this.body.append(section); + this.startPolling(); + } + + async chooseName(name) { + await this.run('SHARING YOUR NAME CHOICE', async () => { + this.invitation = await this.service.chooseName(this.invitation.invitationId, name); + this.renderInvitation(); + }, () => this.renderNaming()); + } + + async renderCommitted() { + this.stopPolling(); + this.clear(); + let projection = null; + try { + projection = await this.service.getProjection(this.invitation.sharedCreatureId); + } catch (_) { + // The committed receipt remains resumable if projection refresh pauses. + } + const panel = element('section', 'shared-guardianship-complete'); + panel.append( + element('p', 'shared-guardianship-eyebrow', 'ONE LIFE // TWO SANCTUARIES'), + element('h3', '', projection?.name || 'Shared creature formed'), + element('p', '', 'This is one creature, not a copy. It will remain visible in both Sanctuaries, and either guardian can care for it while the other is away.'), + button('shared-guardianship-primary', 'RETURN TO SANCTUARY', () => this.complete()) + ); + this.body.append(panel); + } + + startPolling() { + this.stopPolling(); + const generation = this.pollGeneration; + this.pollTimer = window.setInterval(async () => { + if (!this.invitation || this.busy || this.pollInFlight) return; + this.pollInFlight = true; + try { + const previous = this.invitation; + const current = await this.service.get(previous.invitationId); + if (generation !== this.pollGeneration || !this.root) return; + current.code = previous.code; + if (JSON.stringify(current) !== JSON.stringify(previous)) { + this.invitation = current; + this.renderInvitation(); + } + } catch (_) { + // Keep the safe local view and retry; solo gameplay is never blocked. + } finally { + if (generation === this.pollGeneration) { + this.pollInFlight = false; + } + } + }, 2500); + } + + stopPolling() { + if (this.pollTimer) window.clearInterval(this.pollTimer); + this.pollTimer = null; + this.pollGeneration++; + this.pollInFlight = false; + } + + async cancel() { + await this.run('CANCELLING PRIVATE LINK', async () => { + await this.service.cancel(this.invitation.invitationId); + this.renderHome('Private link cancelled. No creature was created.'); + }, () => this.renderInvitation()); + } + + async run(label, action, fallback) { + if (this.busy) return; + this.busy = true; + this.stopPolling(); + this.renderBusy(label); + try { + await action(); + } catch (error) { + fallback?.(); + this.body.append(element('p', 'shared-guardianship-notice', error.message)); + } finally { + this.busy = false; + } + } + + renderError(error) { + this.clear(); + const panel = element('section', 'shared-guardianship-complete'); + panel.append( + element('h3', '', 'Connection paused'), + element('p', '', error?.message || 'Shared Guardianship is temporarily unavailable. Your solo game and creatures are safe.'), + button('shared-guardianship-primary', 'TRY AGAIN', () => this.start()), + button('shared-guardianship-secondary', 'CLOSE', () => this.close()) + ); + this.body.append(panel); + } + + complete() { + this.onComplete?.(this.invitation); + this.close(false); + } + + close(notify = true) { + if (!this.root) return; + this.stopPolling(); + this.service?.destroy?.(); + this.root.removeEventListener('keydown', this.keyboardHandler, true); + this.domElement?.destroy?.(); + if (this.previousFocus?.isConnected) { + this.previousFocus.focus({ preventScroll: true }); + } + this.previousFocus = null; + this.root = null; + this.body = null; + if (notify) this.onClose?.(); + } + + destroy() { + this.close(false); + } +} diff --git a/supabase/config.toml b/supabase/config.toml index d3f900d0..a23a8f4a 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -177,7 +177,7 @@ enable_signup = true # Allow/disallow anonymous sign-ins to your project. enable_anonymous_sign_ins = true # Allow/disallow testing manual linking of accounts -enable_manual_linking = false +enable_manual_linking = true # Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. minimum_password_length = 6 # Passwords that do not meet the following requirements will be rejected as weak. Supported values diff --git a/supabase/functions/delete-cloud-identity/index.ts b/supabase/functions/delete-cloud-identity/index.ts index 1cffe8e5..2ad9085f 100644 --- a/supabase/functions/delete-cloud-identity/index.ts +++ b/supabase/functions/delete-cloud-identity/index.ts @@ -82,7 +82,18 @@ Deno.serve(async (request) => { return jsonResponse(401, { error: 'Cloud identity could not be verified' }); } if (!user.is_anonymous) { - return jsonResponse(403, { error: 'Only anonymous cloud identities can self-delete' }); + const body = await request.json().catch(() => ({})) as Record; + const lastSignInAt = Date.parse(String(user.last_sign_in_at || '')); + const recentlyReauthenticated = Number.isFinite(lastSignInAt) && + Date.now() - lastSignInAt <= 5 * 60 * 1000; + if ( + body.confirmation !== 'DELETE_PERMANENT_ACCOUNT' || + !recentlyReauthenticated + ) { + return jsonResponse(403, { + error: 'Permanent account deletion requires recent password confirmation' + }); + } } const adminClient = createClient(supabaseUrl, secretKey, { diff --git a/supabase/functions/execute-fusion/index.ts b/supabase/functions/execute-fusion/index.ts index 0bab805f..4db8ab74 100644 --- a/supabase/functions/execute-fusion/index.ts +++ b/supabase/functions/execute-fusion/index.ts @@ -104,6 +104,39 @@ function sharedParticipantResponse( }; } +function sharedGuardianshipParticipantResponse( + invitationId: string, + operationId: string, + role: string, + result: unknown, + receipt: unknown, + replay: boolean +) { + const outcome = asObject(result); + const offspring = Array.isArray(outcome.offspring) + ? outcome.offspring as JsonObject[] + : []; + const selected = asObject(offspring[0]); + const selectedData = { ...asObject(selected.offspringData) }; + delete selectedData.parentIds; + return { + guardianshipInvitationId: invitationId, + operationId, + status: 'staged', + role, + offspring: { + offspringGenes: asObject(selected.offspringGenes), + offspringData: selectedData + }, + compatibilityScore: Number(outcome.compatibilityScore) || 0, + birthEvents: Array.isArray(outcome.birthEvents) + ? outcome.birthEvents + : [], + receipt: asObject(receipt), + replay + }; +} + function pick(values: T[], random: () => number): T { return values[Math.floor(random() * values.length)] || values[0]; } @@ -769,12 +802,22 @@ Deno.serve(async (request) => { const hasInvitation = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( invitationId ); + const guardianshipInvitationId = typeof body.guardianshipInvitationId === 'string' + ? body.guardianshipInvitationId.toLowerCase() + : ''; + const hasGuardianshipInvitation = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test( + guardianshipInvitationId + ); if ( !hasInvitation && + !hasGuardianshipInvitation && !/^fusion_[A-Za-z0-9_-]{1,160}$/.test(operationId) ) { return jsonResponse(400, { error: 'Invalid Fusion operation' }); } + if (hasInvitation && hasGuardianshipInvitation) { + return jsonResponse(400, { error: 'Choose one protected Fusion mode' }); + } const callerClient = createClient(supabaseUrl, publishableKey, { global: { @@ -797,7 +840,38 @@ Deno.serve(async (request) => { }); let operationOwnerId = userResult.data.user.id; let sharedRole = ''; - if (hasInvitation) { + let guardianshipRole = ''; + if (hasGuardianshipInvitation) { + const resolved = await serviceClient.rpc( + 'resolve_shared_guardianship_execution', + { + p_user_id: userResult.data.user.id, + p_invitation_id: guardianshipInvitationId + } + ); + if (resolved.error) { + console.error( + '[execute-fusion] Shared Guardianship invitation failed:', + resolved.error.message + ); + return jsonResponse(409, { + error: 'Shared Guardianship invitation is not executable' + }); + } + const resolution = asObject(resolved.data); + operationOwnerId = String(resolution.operationOwnerId || ''); + operationId = String(resolution.operationId || ''); + guardianshipRole = String(resolution.role || ''); + if ( + !operationOwnerId || + !/^fusion_guardianship_[A-Za-z0-9_-]{1,160}$/.test(operationId) || + !['host', 'guest'].includes(guardianshipRole) + ) { + return jsonResponse(409, { + error: 'Shared Guardianship invitation is invalid' + }); + } + } else if (hasInvitation) { const resolved = await serviceClient.rpc( 'resolve_shared_fusion_execution', { @@ -830,7 +904,9 @@ Deno.serve(async (request) => { } const contextResult = await serviceClient.rpc( - 'get_fusion_execution_context', + hasGuardianshipInvitation + ? 'get_shared_guardianship_execution_context' + : 'get_fusion_execution_context', { p_user_id: operationOwnerId, p_operation_id: operationId @@ -847,6 +923,16 @@ Deno.serve(async (request) => { }); } if (context.replay === true && context.result && context.receipt) { + if (guardianshipRole) { + return jsonResponse(200, sharedGuardianshipParticipantResponse( + guardianshipInvitationId, + operationId, + guardianshipRole, + context.result, + context.receipt, + true + )); + } if (sharedRole) { return jsonResponse(200, sharedParticipantResponse( invitationId, @@ -883,7 +969,9 @@ Deno.serve(async (request) => { receiptFingerprint: fingerprint(receiptBase) }; const staged = await serviceClient.rpc( - 'stage_fusion_operation_result', + hasGuardianshipInvitation + ? 'stage_shared_guardianship_result' + : 'stage_fusion_operation_result', { p_user_id: operationOwnerId, p_operation_id: operationId, @@ -896,6 +984,16 @@ Deno.serve(async (request) => { console.error('[execute-fusion] Stage failed:', staged.error.message); return jsonResponse(409, { error: 'Fusion result could not be staged' }); } + if (guardianshipRole) { + return jsonResponse(200, sharedGuardianshipParticipantResponse( + guardianshipInvitationId, + operationId, + guardianshipRole, + staged.data.result, + staged.data.receipt, + Boolean(staged.data.replay) + )); + } if (sharedRole) { return jsonResponse(200, sharedParticipantResponse( invitationId, diff --git a/supabase/migrations/20260831000200_create_shared_guardianship.sql b/supabase/migrations/20260831000200_create_shared_guardianship.sql new file mode 100644 index 00000000..edf3c5ae --- /dev/null +++ b/supabase/migrations/20260831000200_create_shared_guardianship.sql @@ -0,0 +1,1134 @@ +-- Shared Guardianship V1: one canonical child, two private guardians. +-- Existing two-sibling Shared Fusion records and functions are intentionally +-- left unchanged as the rollback boundary. + +create table if not exists public.shared_guardianship_profiles ( + user_id uuid primary key references auth.users(id) on delete cascade, + age_band text not null check (age_band in ('age_16_17', 'age_18_plus')), + terms_version text not null check (terms_version ~ '^[A-Za-z0-9_-]{1,80}$'), + privacy_version text not null check (privacy_version ~ '^[A-Za-z0-9_-]{1,80}$'), + attested_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.shared_guardianship_invitations ( + invitation_id uuid primary key default gen_random_uuid(), + code_hash text not null unique check (code_hash ~ '^[0-9a-f]{64}$'), + create_idempotency_key text not null check (create_idempotency_key ~ '^[A-Za-z0-9_-]{8,120}$'), + host_user_id uuid not null references auth.users(id) on delete cascade, + guest_user_id uuid references auth.users(id) on delete cascade, + host_parent_id text not null check (host_parent_id ~ '^[A-Za-z0-9_-]{1,180}$'), + guest_parent_id text check (guest_parent_id is null or guest_parent_id ~ '^[A-Za-z0-9_-]{1,180}$'), + host_parent_fingerprint text not null check (host_parent_fingerprint ~ '^[0-9a-f]{32}$'), + guest_parent_fingerprint text check (guest_parent_fingerprint is null or guest_parent_fingerprint ~ '^[0-9a-f]{32}$'), + host_parent_record jsonb not null check (jsonb_typeof(host_parent_record) = 'object') check (octet_length(host_parent_record::text) <= 32768), + guest_parent_record jsonb check (guest_parent_record is null or jsonb_typeof(guest_parent_record) = 'object') check (guest_parent_record is null or octet_length(guest_parent_record::text) <= 32768), + host_save_revision bigint not null check (host_save_revision > 0), + guest_save_revision bigint check (guest_save_revision > 0), + host_confirmed_at timestamptz, + guest_confirmed_at timestamptz, + host_name_choice text, + guest_name_choice text, + status text not null default 'waiting' check (status in ('waiting', 'paired', 'ready', 'executing', 'staged', 'committed', 'cancelled', 'expired')), + operation_id text unique check (operation_id is null or operation_id ~ '^fusion_guardianship_[A-Za-z0-9_-]{1,160}$'), + child_id uuid unique, + child_runtime_id text unique check (child_runtime_id is null or child_runtime_id ~ '^creature_guardianship_[A-Za-z0-9_-]{1,180}$'), + result_receipt jsonb, + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()), + expires_at timestamptz not null default timezone('utc', now()) + interval '30 minutes', + committed_at timestamptz, + check (guest_user_id is null or guest_user_id <> host_user_id), + check ( + (guest_user_id is null and guest_parent_id is null and guest_parent_fingerprint is null and guest_parent_record is null and guest_save_revision is null) + or + (guest_user_id is not null and guest_parent_id is not null and guest_parent_fingerprint is not null and guest_parent_record is not null and guest_save_revision is not null) + ) +); + +create unique index if not exists shared_guardianship_invite_create_replay_idx + on public.shared_guardianship_invitations(host_user_id, create_idempotency_key); + +create table if not exists public.shared_guardianship_join_attempts ( + attempt_id bigint generated always as identity primary key, + user_id uuid not null references auth.users(id) on delete cascade, + attempted_at timestamptz not null default timezone('utc', now()) +); + +create table if not exists public.shared_guardianship_creatures ( + creature_id uuid primary key, + runtime_id text not null unique check (runtime_id ~ '^creature_guardianship_[A-Za-z0-9_-]{1,180}$'), + source_invitation_id uuid unique references public.shared_guardianship_invitations(invitation_id) on delete set null, + name text not null check (name in ('Aster', 'Beacon', 'Cinder', 'Echo', 'Lumen', 'Nova', 'Orbit', 'Solace')), + genes jsonb not null check (jsonb_typeof(genes) = 'object') check (octet_length(genes::text) <= 262144), + lifecycle jsonb not null check (jsonb_typeof(lifecycle) = 'object'), + care_state jsonb not null check (jsonb_typeof(care_state) = 'object'), + revision bigint not null default 1 check (revision > 0), + status text not null default 'active' check (status in ('active', 'archived')), + created_at timestamptz not null default timezone('utc', now()), + updated_at timestamptz not null default timezone('utc', now()) +); + +alter table public.shared_guardianship_invitations + add constraint shared_guardianship_invitation_child_fk + foreign key (child_id) references public.shared_guardianship_creatures(creature_id) on delete set null + deferrable initially deferred; + +create table if not exists public.shared_guardianship_participants ( + creature_id uuid not null references public.shared_guardianship_creatures(creature_id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + role text not null check (role in ('host', 'guest')), + guardian_label text not null check (guardian_label in ('Guardian A', 'Guardian B')), + status text not null default 'active' check (status in ('active', 'left')), + terms_version text not null, + privacy_version text not null, + notifications_muted boolean not null default false, + joined_at timestamptz not null default timezone('utc', now()), + left_at timestamptz, + primary key (creature_id, user_id), + unique (creature_id, role) +); + +create table if not exists public.shared_guardianship_parentage ( + creature_id uuid not null references public.shared_guardianship_creatures(creature_id) on delete cascade, + source_role text not null check (source_role in ('host', 'guest')), + parent_fingerprint text not null check (parent_fingerprint ~ '^[0-9a-f]{32}$'), + parent_reference text not null check (parent_reference ~ '^protected-parent-v1:[0-9a-f]{32}$'), + primary key (creature_id, source_role) +); + +create table if not exists public.shared_guardianship_events ( + event_id uuid primary key default gen_random_uuid(), + creature_id uuid not null references public.shared_guardianship_creatures(creature_id) on delete cascade, + actor_user_id uuid references auth.users(id) on delete set null, + actor_label text not null check (actor_label in ('Guardian A', 'Guardian B', 'The Fusion Pod')), + idempotency_key text not null check (idempotency_key ~ '^[A-Za-z0-9_-]{8,120}$'), + event_kind text not null check (event_kind in ('birth', 'tend', 'play', 'rest', 'departure')), + summary text not null check (char_length(summary) between 1 and 160), + payload jsonb not null default '{}'::jsonb check (jsonb_typeof(payload) = 'object') check (octet_length(payload::text) <= 8192), + before_revision bigint not null check (before_revision >= 0), + after_revision bigint not null check (after_revision > before_revision), + created_at timestamptz not null default timezone('utc', now()), + unique (creature_id, idempotency_key) +); + +create table if not exists public.shared_guardianship_commands ( + command_id uuid primary key default gen_random_uuid(), + creature_id uuid not null references public.shared_guardianship_creatures(creature_id) on delete cascade, + actor_user_id uuid references auth.users(id) on delete set null, + idempotency_key text not null check (idempotency_key ~ '^[A-Za-z0-9_-]{8,120}$'), + command_kind text not null check (command_kind in ('care', 'notifications', 'leave')), + result jsonb not null default '{}'::jsonb check (jsonb_typeof(result) = 'object') check (octet_length(result::text) <= 8192), + created_at timestamptz not null default timezone('utc', now()), + unique (creature_id, idempotency_key) +); + +create index if not exists shared_guardianship_invite_host_live_idx on public.shared_guardianship_invitations(host_user_id, expires_at desc); +create index if not exists shared_guardianship_invite_guest_live_idx on public.shared_guardianship_invitations(guest_user_id, expires_at desc) where guest_user_id is not null; +create index if not exists shared_guardianship_join_attempts_idx on public.shared_guardianship_join_attempts(user_id, attempted_at desc); +create index if not exists shared_guardianship_events_creature_idx on public.shared_guardianship_events(creature_id, after_revision desc); +create index if not exists shared_guardianship_commands_created_idx on public.shared_guardianship_commands(created_at); +create unique index if not exists shared_guardianship_one_active_per_user_idx +on public.shared_guardianship_participants(user_id) +where status = 'active'; + +alter table public.shared_guardianship_profiles enable row level security; +alter table public.shared_guardianship_profiles force row level security; +alter table public.shared_guardianship_invitations enable row level security; +alter table public.shared_guardianship_invitations force row level security; +alter table public.shared_guardianship_join_attempts enable row level security; +alter table public.shared_guardianship_join_attempts force row level security; +alter table public.shared_guardianship_creatures enable row level security; +alter table public.shared_guardianship_creatures force row level security; +alter table public.shared_guardianship_participants enable row level security; +alter table public.shared_guardianship_participants force row level security; +alter table public.shared_guardianship_parentage enable row level security; +alter table public.shared_guardianship_parentage force row level security; +alter table public.shared_guardianship_events enable row level security; +alter table public.shared_guardianship_events force row level security; +alter table public.shared_guardianship_commands enable row level security; +alter table public.shared_guardianship_commands force row level security; + +revoke all on table public.shared_guardianship_profiles from anon, authenticated; +revoke all on table public.shared_guardianship_invitations from anon, authenticated; +revoke all on table public.shared_guardianship_join_attempts from anon, authenticated; +revoke all on table public.shared_guardianship_creatures from anon, authenticated; +revoke all on table public.shared_guardianship_participants from anon, authenticated; +revoke all on table public.shared_guardianship_parentage from anon, authenticated; +revoke all on table public.shared_guardianship_events from anon, authenticated; +revoke all on table public.shared_guardianship_commands from anon, authenticated; + +create or replace function public.shared_guardianship_user_is_permanent(p_user_id uuid) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select exists ( + select 1 from auth.users as account + where account.id = p_user_id + and account.is_anonymous is false + and account.email is not null + and account.email_confirmed_at is not null + ); +$$; + +create or replace function public.shared_guardianship_require_user() +returns uuid +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := auth.uid(); +begin + if v_user_id is null then + raise exception 'authentication_required' using errcode = '42501'; + end if; + if not public.shared_guardianship_user_is_permanent(v_user_id) then + raise exception 'shared_guardianship_permanent_identity_required' using errcode = '42501'; + end if; + return v_user_id; +end; +$$; + +create or replace function public.shared_guardianship_user_is_eligible(p_user_id uuid) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select public.shared_guardianship_user_is_permanent(p_user_id) + and exists ( + select 1 + from public.shared_guardianship_profiles as profile + where profile.user_id = p_user_id + and profile.age_band in ('age_16_17', 'age_18_plus') + and profile.terms_version = 'shared-guardianship-2026-08-31' + and profile.privacy_version = 'shared-guardianship-2026-08-31' + ); +$$; + +create or replace function public.shared_guardianship_require_eligible_user() +returns uuid +language plpgsql +stable +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_user(); +begin + if not public.shared_guardianship_user_is_eligible(v_user_id) then + raise exception 'shared_guardianship_eligibility_required' using errcode = '42501'; + end if; + return v_user_id; +end; +$$; + +create or replace function public.shared_guardianship_invitation_view( + p_invitation public.shared_guardianship_invitations, + p_user_id uuid +) +returns jsonb +language plpgsql +stable +set search_path = '' +as $$ +declare + v_role text; + v_peer jsonb; + v_own_parent_id text; +begin + if p_user_id = p_invitation.host_user_id then + v_role := 'host'; + v_peer := p_invitation.guest_parent_record; + v_own_parent_id := p_invitation.host_parent_id; + elsif p_user_id = p_invitation.guest_user_id then + v_role := 'guest'; + v_peer := p_invitation.host_parent_record; + v_own_parent_id := p_invitation.guest_parent_id; + else + raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; + end if; + return jsonb_strip_nulls(jsonb_build_object( + 'schemaVersion', 1, + 'invitationId', p_invitation.invitation_id, + 'role', v_role, + 'status', p_invitation.status, + 'ownParentId', v_own_parent_id, + 'peerSignal', case when v_peer is null then null else jsonb_build_object( + 'rarity', coalesce(v_peer->>'rarity', 'common'), + 'affinity', coalesce(v_peer#>>'{cosmicAffinity,element}', v_peer->>'cosmicAffinity', 'unclassified'), + 'generation', case when coalesce(v_peer->>'generation', '') ~ '^[0-9]+$' then (v_peer->>'generation')::integer else 1 end, + 'stage', coalesce(v_peer#>>'{lifecycle,stage}', 'adult') + ) end, + 'hostConfirmed', p_invitation.host_confirmed_at is not null, + 'guestConfirmed', p_invitation.guest_confirmed_at is not null, + 'ownNameChoice', case when v_role = 'host' then p_invitation.host_name_choice else p_invitation.guest_name_choice end, + 'peerNameChoice', case when v_role = 'host' then p_invitation.guest_name_choice else p_invitation.host_name_choice end, + 'nameAgreed', p_invitation.host_name_choice is not null and p_invitation.host_name_choice = p_invitation.guest_name_choice, + 'createdAt', p_invitation.created_at, + 'expiresAt', p_invitation.expires_at, + 'operationId', p_invitation.operation_id, + 'sharedCreatureId', p_invitation.child_id, + 'sharedRuntimeId', p_invitation.child_runtime_id + )); +end; +$$; + +create or replace function public.attest_shared_guardianship_eligibility( + p_age_band text, + p_terms_version text, + p_privacy_version text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_user(); +begin + if p_age_band not in ('age_16_17', 'age_18_plus') + or p_terms_version <> 'shared-guardianship-2026-08-31' + or p_privacy_version <> 'shared-guardianship-2026-08-31' then + raise exception 'shared_guardianship_eligibility_required' using errcode = '42501'; + end if; + insert into public.shared_guardianship_profiles(user_id, age_band, terms_version, privacy_version) + values (v_user_id, p_age_band, p_terms_version, p_privacy_version) + on conflict (user_id) do update set + age_band = excluded.age_band, + terms_version = excluded.terms_version, + privacy_version = excluded.privacy_version, + attested_at = timezone('utc', now()), + updated_at = timezone('utc', now()); + return jsonb_build_object('eligible', true, 'termsVersion', p_terms_version, 'privacyVersion', p_privacy_version); +end; +$$; + +create or replace function public.create_shared_guardianship_invitation( + p_parent_id text, + p_expected_revision bigint, + p_idempotency_key text +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_revision bigint; + v_game_state jsonb; + v_parent jsonb; + v_code text; + v_hash text; + v_invitation public.shared_guardianship_invitations; +begin + if p_idempotency_key !~ '^[A-Za-z0-9_-]{8,120}$' then + raise exception 'shared_guardianship_request_invalid' using errcode = '22023'; + end if; + select * into v_invitation + from public.shared_guardianship_invitations + where host_user_id = v_user_id + and create_idempotency_key = p_idempotency_key; + if found then + v_code := upper(substr(encode(extensions.digest(v_user_id::text || ':' || p_idempotency_key, 'sha256'), 'hex'), 1, 12)); + return public.shared_guardianship_invitation_view(v_invitation, v_user_id) || jsonb_build_object( + 'code', substr(v_code,1,4) || '-' || substr(v_code,5,4) || '-' || substr(v_code,9,4), + 'replay', true + ); + end if; + if not exists (select 1 from public.shared_guardianship_profiles where user_id = v_user_id) then + raise exception 'shared_guardianship_eligibility_required' using errcode = '42501'; + end if; + if exists (select 1 from public.shared_guardianship_participants where user_id = v_user_id and status = 'active') then + raise exception 'shared_guardianship_limit_reached' using errcode = '22023'; + end if; + delete from public.shared_guardianship_join_attempts + where attempted_at < timezone('utc', now()) - interval '24 hours'; + update public.shared_guardianship_invitations set status = 'expired', updated_at = timezone('utc', now()) + where (host_user_id = v_user_id or guest_user_id = v_user_id) + and status in ('waiting', 'paired', 'ready') and expires_at <= timezone('utc', now()); + delete from public.shared_guardianship_invitations + where status in ('cancelled', 'expired', 'committed') + and coalesce(committed_at, updated_at) < timezone('utc', now()) - interval '30 days'; + if exists ( + select 1 from public.shared_guardianship_invitations + where (host_user_id = v_user_id or guest_user_id = v_user_id) + and status in ('waiting', 'paired', 'ready', 'executing', 'staged') + and expires_at > timezone('utc', now()) + ) then + raise exception 'shared_guardianship_invitation_limit' using errcode = '22023'; + end if; + select revision, game_state into v_revision, v_game_state + from public.game_saves where user_id = v_user_id and save_slot = 'primary' for update; + if not found then raise exception 'shared_guardianship_cloud_save_required' using errcode = '22023'; end if; + if p_expected_revision is null or p_expected_revision <> v_revision then + raise exception 'save_revision_conflict' using errcode = '40001'; + end if; + v_parent := public.shared_fusion_parent_record(v_game_state, p_parent_id); + if v_parent is null then raise exception 'shared_guardianship_parent_unavailable' using errcode = '42501'; end if; + v_code := upper(substr(encode(extensions.digest(v_user_id::text || ':' || p_idempotency_key, 'sha256'), 'hex'), 1, 12)); + v_hash := encode(extensions.digest(v_code, 'sha256'), 'hex'); + if exists (select 1 from public.shared_guardianship_invitations where code_hash = v_hash) then + raise exception 'shared_guardianship_code_generation_failed' using errcode = '40001'; + end if; + insert into public.shared_guardianship_invitations( + code_hash, create_idempotency_key, host_user_id, host_parent_id, host_parent_fingerprint, host_parent_record, host_save_revision + ) values ( + v_hash, p_idempotency_key, v_user_id, p_parent_id, md5(v_parent::text), v_parent, v_revision + ) returning * into v_invitation; + return public.shared_guardianship_invitation_view(v_invitation, v_user_id) || jsonb_build_object( + 'code', substr(v_code,1,4) || '-' || substr(v_code,5,4) || '-' || substr(v_code,9,4), + 'replay', false + ); +end; +$$; + +create or replace function public.join_shared_guardianship_invitation( + p_code text, + p_parent_id text, + p_expected_revision bigint +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_compact text := upper(regexp_replace(coalesce(p_code, ''), '[^0-9A-F]', '', 'g')); + v_hash text; + v_attempts integer; + v_revision bigint; + v_game_state jsonb; + v_parent jsonb; + v_invitation public.shared_guardianship_invitations; +begin + if not exists (select 1 from public.shared_guardianship_profiles where user_id = v_user_id) then + raise exception 'shared_guardianship_eligibility_required' using errcode = '42501'; + end if; + if exists (select 1 from public.shared_guardianship_participants where user_id = v_user_id and status = 'active') then + raise exception 'shared_guardianship_limit_reached' using errcode = '22023'; + end if; + delete from public.shared_guardianship_join_attempts where attempted_at < timezone('utc', now()) - interval '24 hours'; + select count(*) into v_attempts from public.shared_guardianship_join_attempts + where user_id = v_user_id and attempted_at > timezone('utc', now()) - interval '10 minutes'; + if v_attempts >= 8 then + return jsonb_build_object( + 'errorCode', 'shared_guardianship_join_rate_limited', + 'retryAfterSeconds', 600 + ); + end if; + insert into public.shared_guardianship_join_attempts(user_id) values (v_user_id); + if v_compact !~ '^[0-9A-F]{12}$' then + return jsonb_build_object('errorCode', 'shared_guardianship_invitation_unavailable'); + end if; + v_hash := encode(extensions.digest(v_compact, 'sha256'), 'hex'); + select * into v_invitation from public.shared_guardianship_invitations + where code_hash = v_hash for update; + if not found or v_invitation.expires_at <= timezone('utc', now()) or v_invitation.host_user_id = v_user_id then + return jsonb_build_object('errorCode', 'shared_guardianship_invitation_unavailable'); + end if; + if v_invitation.guest_user_id = v_user_id + and v_invitation.status in ('paired','ready','executing','staged','committed') then + return public.shared_guardianship_invitation_view(v_invitation, v_user_id) + || jsonb_build_object('replay', true); + end if; + if v_invitation.status <> 'waiting' or v_invitation.guest_user_id is not null then + return jsonb_build_object('errorCode', 'shared_guardianship_invitation_unavailable'); + end if; + select revision, game_state into v_revision, v_game_state + from public.game_saves where user_id = v_user_id and save_slot = 'primary' for update; + if not found then raise exception 'shared_guardianship_cloud_save_required' using errcode = '22023'; end if; + if p_expected_revision is null or p_expected_revision <> v_revision then raise exception 'save_revision_conflict' using errcode = '40001'; end if; + v_parent := public.shared_fusion_parent_record(v_game_state, p_parent_id); + if v_parent is null then raise exception 'shared_guardianship_parent_unavailable' using errcode = '42501'; end if; + update public.shared_guardianship_invitations set + guest_user_id = v_user_id, + guest_parent_id = p_parent_id, + guest_parent_fingerprint = md5(v_parent::text), + guest_parent_record = v_parent, + guest_save_revision = v_revision, + status = 'paired', + updated_at = timezone('utc', now()) + where invitation_id = v_invitation.invitation_id and status = 'waiting' + returning * into v_invitation; + if not found then raise exception 'shared_guardianship_invitation_unavailable' using errcode = '40001'; end if; + return public.shared_guardianship_invitation_view(v_invitation, v_user_id) + || jsonb_build_object('replay', false); +end; +$$; + +create or replace function public.get_shared_guardianship_invitation(p_invitation_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_invitation public.shared_guardianship_invitations; +begin + update public.shared_guardianship_invitations set status = 'expired', updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id and status in ('waiting','paired','ready') and expires_at <= timezone('utc', now()); + select * into v_invitation from public.shared_guardianship_invitations + where invitation_id = p_invitation_id and (host_user_id = v_user_id or guest_user_id = v_user_id); + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + return public.shared_guardianship_invitation_view(v_invitation, v_user_id); +end; +$$; + +create or replace function public.confirm_shared_guardianship_invitation(p_invitation_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_invitation public.shared_guardianship_invitations; + v_revision bigint; + v_game_state jsonb; + v_parent jsonb; + v_parent_id text; + v_fingerprint text; + v_operation_id text; + v_child_id uuid; + v_runtime_id text; + v_seed text; + v_request jsonb; + v_request_fingerprint text; +begin + select * into v_invitation from public.shared_guardianship_invitations + where invitation_id = p_invitation_id and (host_user_id = v_user_id or guest_user_id = v_user_id) for update; + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_invitation.status not in ('paired','ready') or v_invitation.expires_at <= timezone('utc', now()) then + raise exception 'shared_guardianship_invitation_not_confirmable' using errcode = '22023'; + end if; + if v_user_id = v_invitation.host_user_id then + v_parent_id := v_invitation.host_parent_id; v_fingerprint := v_invitation.host_parent_fingerprint; + else + v_parent_id := v_invitation.guest_parent_id; v_fingerprint := v_invitation.guest_parent_fingerprint; + end if; + select revision, game_state into v_revision, v_game_state from public.game_saves + where user_id = v_user_id and save_slot = 'primary' for update; + if not found then raise exception 'shared_guardianship_cloud_save_required' using errcode = '22023'; end if; + v_parent := public.shared_fusion_parent_record(v_game_state, v_parent_id); + if v_parent is null or md5(v_parent::text) <> v_fingerprint then + update public.shared_guardianship_invitations + set host_confirmed_at = null, + guest_confirmed_at = null, + status = 'cancelled', + updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id; + return jsonb_build_object( + 'errorCode', 'shared_guardianship_parent_changed', + 'cancelled', true + ); + end if; + if v_user_id = v_invitation.host_user_id then + update public.shared_guardianship_invitations set host_confirmed_at = coalesce(host_confirmed_at, timezone('utc', now())), host_save_revision = v_revision, + status = case when guest_confirmed_at is not null then 'ready' else 'paired' end, updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id returning * into v_invitation; + else + update public.shared_guardianship_invitations set guest_confirmed_at = coalesce(guest_confirmed_at, timezone('utc', now())), guest_save_revision = v_revision, + status = case when host_confirmed_at is not null then 'ready' else 'paired' end, updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id returning * into v_invitation; + end if; + if v_invitation.status = 'ready' and v_invitation.operation_id is null then + v_operation_id := 'fusion_guardianship_' || gen_random_uuid()::text; + v_child_id := gen_random_uuid(); + v_runtime_id := 'creature_guardianship_' || v_child_id::text; + v_seed := 'fusion-server-guardianship-v1:' || md5(v_invitation.invitation_id::text || ':' || v_invitation.host_parent_fingerprint || ':' || v_invitation.guest_parent_fingerprint); + v_request := jsonb_build_object( + 'schemaVersion', 1, 'contractVersion', 1, 'operationId', v_operation_id, + 'parentIds', jsonb_build_array(v_invitation.host_parent_id, v_invitation.guest_parent_id), + 'candidateOffspringIds', jsonb_build_array(v_runtime_id), 'offspringCapacity', 1, + 'expectedSaveRevision', v_invitation.host_save_revision, + 'requestedAt', floor(extract(epoch from timezone('utc', now())) * 1000)::bigint, + 'resultSeed', v_seed, 'executionMode', 'server_shared_guardianship', + 'consent', jsonb_build_object( + 'mode', 'shared_guardianship', 'scope', 'protected_invitation', 'keeperGrant', 'dual_confirmed', + 'termsVersion', 'shared-guardianship-2026-08-31', 'privacyVersion', 'shared-guardianship-2026-08-31', + 'parentGrants', jsonb_build_array( + jsonb_build_object('creatureId', v_invitation.host_parent_id, 'grant', 'lineage_synthesis', 'decision', 'willing'), + jsonb_build_object('creatureId', v_invitation.guest_parent_id, 'grant', 'lineage_synthesis', 'decision', 'willing') + ), + 'sharedGuardianshipInvitationId', v_invitation.invitation_id + ) + ); + v_request_fingerprint := 'fnv1a32-v1:' || substr(md5(v_request::text),1,8); + v_request := v_request || jsonb_build_object('requestFingerprint', v_request_fingerprint); + insert into public.fusion_operations( + user_id, operation_id, request_fingerprint, server_fingerprint, parent_ids, offspring_ids, + offspring_count, expected_save_revision, result_seed, request, status, expires_at + ) values ( + v_invitation.host_user_id, v_operation_id, v_request_fingerprint, + md5((v_request - 'requestFingerprint')::text), + array[v_invitation.host_parent_id, v_invitation.guest_parent_id], array[v_runtime_id], + 1, v_invitation.host_save_revision, v_seed, v_request, 'reserved', timezone('utc', now()) + interval '30 minutes' + ); + update public.shared_guardianship_invitations set operation_id = v_operation_id, child_id = v_child_id, + child_runtime_id = v_runtime_id, expires_at = timezone('utc', now()) + interval '30 minutes', updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id returning * into v_invitation; + end if; + return public.shared_guardianship_invitation_view(v_invitation, v_user_id); +end; +$$; + +create or replace function public.resolve_shared_guardianship_execution(p_user_id uuid, p_invitation_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_invitation public.shared_guardianship_invitations; + v_role text; +begin + if not public.shared_guardianship_user_is_eligible(p_user_id) then raise exception 'shared_guardianship_eligibility_required' using errcode = '42501'; end if; + select * into v_invitation from public.shared_guardianship_invitations where invitation_id = p_invitation_id for update; + if not found or v_invitation.status not in ('ready','executing','staged') then raise exception 'shared_guardianship_invitation_not_executable' using errcode = '42501'; end if; + if p_user_id = v_invitation.host_user_id then v_role := 'host'; elsif p_user_id = v_invitation.guest_user_id then v_role := 'guest'; else raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_invitation.host_confirmed_at is null or v_invitation.guest_confirmed_at is null or v_invitation.operation_id is null then + raise exception 'shared_guardianship_dual_consent_required' using errcode = '42501'; + end if; + return jsonb_build_object('invitationId', v_invitation.invitation_id, 'operationOwnerId', v_invitation.host_user_id, 'operationId', v_invitation.operation_id, 'role', v_role, 'status', v_invitation.status); +end; +$$; + +create or replace function public.get_shared_guardianship_execution_context(p_user_id uuid, p_operation_id text) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_operation public.fusion_operations; + v_invitation public.shared_guardianship_invitations; +begin + select * into v_operation from public.fusion_operations where user_id = p_user_id and operation_id = p_operation_id for update; + if not found or v_operation.request#>>'{consent,mode}' <> 'shared_guardianship' then raise exception 'shared_guardianship_operation_not_found' using errcode = '42501'; end if; + select * into v_invitation from public.shared_guardianship_invitations + where host_user_id = p_user_id and operation_id = p_operation_id for update; + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_operation.status = 'staged' and v_operation.result is not null then + return jsonb_build_object('schemaVersion',1,'operationId',v_operation.operation_id,'status',v_operation.status, + 'requestFingerprint',v_operation.request_fingerprint,'serverFingerprint',v_operation.server_fingerprint, + 'resultSeed',v_operation.result_seed,'result',v_operation.result,'receipt',v_operation.result_receipt, + 'sharedGuardianship',true,'replay',true); + end if; + if v_operation.status <> 'reserved' or v_operation.expires_at <= timezone('utc', now()) + or v_invitation.status not in ('ready','executing') or v_invitation.host_confirmed_at is null or v_invitation.guest_confirmed_at is null then + raise exception 'shared_guardianship_operation_not_executable' using errcode = '22023'; + end if; + update public.shared_guardianship_invitations set status = 'executing', updated_at = timezone('utc', now()) + where invitation_id = v_invitation.invitation_id and status = 'ready'; + return jsonb_build_object( + 'schemaVersion',1,'operationId',v_operation.operation_id,'status',v_operation.status, + 'requestFingerprint',v_operation.request_fingerprint,'serverFingerprint',v_operation.server_fingerprint, + 'resultSeed',v_operation.result_seed,'offspringIds',to_jsonb(v_operation.offspring_ids),'offspringCount',1, + 'parentIds',to_jsonb(v_operation.parent_ids),'parentRecords',jsonb_build_array(v_invitation.host_parent_record,v_invitation.guest_parent_record), + 'reservedAt',v_operation.created_at,'sharedGuardianship',true,'replay',false + ); +end; +$$; + +create or replace function public.stage_shared_guardianship_result( + p_user_id uuid, + p_operation_id text, + p_server_fingerprint text, + p_result jsonb, + p_receipt jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_operation public.fusion_operations; + v_invitation public.shared_guardianship_invitations; +begin + select * into v_operation from public.fusion_operations where user_id = p_user_id and operation_id = p_operation_id for update; + if not found or v_operation.request#>>'{consent,mode}' <> 'shared_guardianship' then raise exception 'shared_guardianship_operation_not_found' using errcode = '42501'; end if; + select * into v_invitation from public.shared_guardianship_invitations where host_user_id = p_user_id and operation_id = p_operation_id for update; + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_operation.status = 'staged' and v_operation.result is not null then + if v_operation.result_receipt->>'receiptFingerprint' <> p_receipt->>'receiptFingerprint' then raise exception 'shared_guardianship_result_replay_mismatch' using errcode = '23505'; end if; + return jsonb_build_object('result',v_operation.result,'receipt',v_operation.result_receipt,'replay',true); + end if; + if v_operation.status <> 'reserved' or v_operation.server_fingerprint <> p_server_fingerprint + or p_result->>'operationId' <> p_operation_id or jsonb_array_length(coalesce(p_result->'offspring','[]'::jsonb)) <> 1 + or p_result#>>'{offspring,0,offspringData,creatureId}' <> v_invitation.child_runtime_id + or p_receipt->>'operationId' <> p_operation_id or p_receipt->>'serverFingerprint' <> p_server_fingerprint then + raise exception 'shared_guardianship_result_invalid' using errcode = '42501'; + end if; + update public.fusion_operations set status = 'staged', result = p_result, result_receipt = p_receipt + where user_id = p_user_id and operation_id = p_operation_id returning * into v_operation; + update public.shared_guardianship_invitations set status = 'staged', updated_at = timezone('utc', now()) + where invitation_id = v_invitation.invitation_id; + return jsonb_build_object('result',v_operation.result,'receipt',v_operation.result_receipt,'replay',false); +end; +$$; + +create or replace function public.submit_shared_guardianship_name(p_invitation_id uuid, p_name text) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_name text; + v_invitation public.shared_guardianship_invitations; + v_operation public.fusion_operations; + v_child_result jsonb; + v_now timestamptz := timezone('utc', now()); + v_now_ms bigint; +begin + select names.candidate into v_name + from unnest(array['Aster','Beacon','Cinder','Echo','Lumen','Nova','Orbit','Solace']) as names(candidate) + where lower(names.candidate) = lower(btrim(coalesce(p_name,''))); + if v_name is null then raise exception 'shared_guardianship_name_invalid' using errcode = '22023'; end if; + select * into v_invitation from public.shared_guardianship_invitations + where invitation_id = p_invitation_id and (host_user_id = v_user_id or guest_user_id = v_user_id) for update; + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_invitation.status = 'committed' then return public.shared_guardianship_invitation_view(v_invitation,v_user_id) || jsonb_build_object('replay',true); end if; + if v_invitation.status <> 'staged' then raise exception 'shared_guardianship_result_not_ready' using errcode = '22023'; end if; + if v_user_id = v_invitation.host_user_id then + update public.shared_guardianship_invitations set host_name_choice = v_name, updated_at = v_now where invitation_id = p_invitation_id returning * into v_invitation; + else + update public.shared_guardianship_invitations set guest_name_choice = v_name, updated_at = v_now where invitation_id = p_invitation_id returning * into v_invitation; + end if; + if v_invitation.host_name_choice is null or v_invitation.guest_name_choice is null or v_invitation.host_name_choice <> v_invitation.guest_name_choice then + return public.shared_guardianship_invitation_view(v_invitation,v_user_id) || jsonb_build_object('awaitingAgreement',true); + end if; + select * into v_operation from public.fusion_operations where user_id = v_invitation.host_user_id and operation_id = v_invitation.operation_id for update; + if not found or v_operation.status <> 'staged' then raise exception 'shared_guardianship_staged_result_invalid' using errcode = '42501'; end if; + v_child_result := v_operation.result->'offspring'->0; + v_now_ms := floor(extract(epoch from v_now) * 1000)::bigint; + insert into public.shared_guardianship_creatures( + creature_id,runtime_id,source_invitation_id,name,genes,lifecycle,care_state + ) values ( + v_invitation.child_id,v_invitation.child_runtime_id,v_invitation.invitation_id,v_name, + coalesce(v_child_result->'offspringGenes','{}'::jsonb), + jsonb_build_object('stage','baby','birthDate',v_now_ms,'lastStageChange',v_now_ms), + jsonb_build_object('comfort',100,'curiosity',50,'energy',100,'lastCareAt',v_now_ms) + ) on conflict (creature_id) do nothing; + insert into public.shared_guardianship_participants(creature_id,user_id,role,guardian_label,terms_version,privacy_version) + values + (v_invitation.child_id,v_invitation.host_user_id,'host','Guardian A','shared-guardianship-2026-08-31','shared-guardianship-2026-08-31'), + (v_invitation.child_id,v_invitation.guest_user_id,'guest','Guardian B','shared-guardianship-2026-08-31','shared-guardianship-2026-08-31') + on conflict do nothing; + insert into public.shared_guardianship_parentage(creature_id,source_role,parent_fingerprint,parent_reference) + values + (v_invitation.child_id,'host',v_invitation.host_parent_fingerprint,'protected-parent-v1:' || md5(v_invitation.invitation_id::text || ':host')), + (v_invitation.child_id,'guest',v_invitation.guest_parent_fingerprint,'protected-parent-v1:' || md5(v_invitation.invitation_id::text || ':guest')) + on conflict do nothing; + insert into public.shared_guardianship_events(creature_id,actor_user_id,actor_label,idempotency_key,event_kind,summary,before_revision,after_revision) + values (v_invitation.child_id,null,'The Fusion Pod','birth_' || replace(v_invitation.invitation_id::text,'-',''),'birth',v_name || ' awakened between two Sanctuaries.',0,1) + on conflict do nothing; + update public.fusion_operations set status = 'committed', completed_at = v_now where user_id = v_invitation.host_user_id and operation_id = v_invitation.operation_id; + update public.shared_guardianship_invitations set status = 'committed', committed_at = v_now, updated_at = v_now, result_receipt = v_operation.result_receipt + where invitation_id = p_invitation_id returning * into v_invitation; + return public.shared_guardianship_invitation_view(v_invitation,v_user_id) || jsonb_build_object('awaitingAgreement',false,'replay',false); +end; +$$; + +create or replace function public.get_shared_guardianship_projection(p_creature_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_creature public.shared_guardianship_creatures; + v_participant public.shared_guardianship_participants; + v_history jsonb; +begin + select * into v_participant from public.shared_guardianship_participants + where creature_id = p_creature_id and user_id = v_user_id and status = 'active'; + if not found then raise exception 'shared_guardianship_access_denied' using errcode = '42501'; end if; + select * into v_creature from public.shared_guardianship_creatures where creature_id = p_creature_id and status = 'active'; + if not found then raise exception 'shared_guardianship_creature_not_found' using errcode = '22023'; end if; + select coalesce(jsonb_agg(jsonb_build_object('kind',event_kind,'summary',summary,'guardianLabel',actor_label,'revision',after_revision,'createdAt',created_at) order by after_revision desc),'[]'::jsonb) + into v_history from (select * from public.shared_guardianship_events where creature_id = p_creature_id order by after_revision desc limit 20) events; + return jsonb_build_object( + 'schemaVersion',1,'sharedCreatureId',v_creature.creature_id,'runtimeId',v_creature.runtime_id,'name',v_creature.name, + 'genes',v_creature.genes,'lifecycle',v_creature.lifecycle,'care',v_creature.care_state,'revision',v_creature.revision, + 'status',v_creature.status, + 'guardianCount',(select count(*)::integer from public.shared_guardianship_participants where creature_id = p_creature_id and status = 'active'), + 'guardianRole',v_participant.role,'guardianLabel',v_participant.guardian_label, + 'notificationsMuted',v_participant.notifications_muted,'history',v_history,'updatedAt',v_creature.updated_at + ); +end; +$$; + +create or replace function public.list_shared_guardianship_creatures() +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_result jsonb := '[]'::jsonb; + v_id uuid; +begin + for v_id in select creature_id from public.shared_guardianship_participants where user_id = v_user_id and status = 'active' order by joined_at loop + v_result := v_result || jsonb_build_array(public.get_shared_guardianship_projection(v_id)); + end loop; + return v_result; +end; +$$; + +create or replace function public.perform_shared_guardianship_care( + p_creature_id uuid, + p_action text, + p_idempotency_key text, + p_expected_revision bigint +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_creature public.shared_guardianship_creatures; + v_participant public.shared_guardianship_participants; + v_command public.shared_guardianship_commands; + v_comfort integer; + v_curiosity integer; + v_energy integer; + v_summary text; + v_rebased boolean := false; + v_now_ms bigint := floor(extract(epoch from timezone('utc', now())) * 1000)::bigint; +begin + if p_action not in ('tend','play','rest') or p_idempotency_key !~ '^[A-Za-z0-9_-]{8,120}$' then raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; end if; + select * into v_participant from public.shared_guardianship_participants + where creature_id = p_creature_id and user_id = v_user_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_access_denied' using errcode = '42501'; end if; + select * into v_creature from public.shared_guardianship_creatures where creature_id = p_creature_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_creature_not_found' using errcode = '22023'; end if; + select * into v_command from public.shared_guardianship_commands + where creature_id = p_creature_id and idempotency_key = p_idempotency_key; + if found then + if v_command.actor_user_id is distinct from v_user_id or v_command.command_kind <> 'care' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay',true,'rebased',false); + end if; + if p_expected_revision is null + or p_expected_revision > v_creature.revision + or p_expected_revision < v_creature.revision - 1 then + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('conflict',true,'replay',false,'rebased',false); + end if; + v_rebased := p_expected_revision = v_creature.revision - 1; + v_comfort := least(100,greatest(0,coalesce((v_creature.care_state->>'comfort')::integer,50) + case when p_action='tend' then 8 when p_action='rest' then 3 else 2 end)); + v_curiosity := least(100,greatest(0,coalesce((v_creature.care_state->>'curiosity')::integer,50) + case when p_action='play' then 8 when p_action='tend' then 2 else 1 end)); + v_energy := least(100,greatest(0,coalesce((v_creature.care_state->>'energy')::integer,50) + case when p_action='rest' then 10 when p_action='play' then -4 else 2 end)); + v_summary := case p_action when 'tend' then v_participant.guardian_label || ' tended the shared habitat.' when 'play' then v_participant.guardian_label || ' explored a new response.' else v_participant.guardian_label || ' helped the creature rest.' end; + update public.shared_guardianship_creatures set + care_state = jsonb_build_object('comfort',v_comfort,'curiosity',v_curiosity,'energy',v_energy,'lastCareAt',v_now_ms), + revision = revision + 1, updated_at = timezone('utc', now()) + where creature_id = p_creature_id returning * into v_creature; + insert into public.shared_guardianship_events(creature_id,actor_user_id,actor_label,idempotency_key,event_kind,summary,payload,before_revision,after_revision) + values (p_creature_id,v_user_id,v_participant.guardian_label,p_idempotency_key,p_action,v_summary,jsonb_build_object('action',p_action),v_creature.revision-1,v_creature.revision); + insert into public.shared_guardianship_commands(creature_id,actor_user_id,idempotency_key,command_kind,result) + values (p_creature_id,v_user_id,p_idempotency_key,'care',jsonb_build_object('acceptedRevision',v_creature.revision)); + delete from public.shared_guardianship_events + where event_id in ( + select event_id from public.shared_guardianship_events + where creature_id = p_creature_id + order by after_revision desc + offset 100 + ); + delete from public.shared_guardianship_commands + where command_id in ( + select command_id from public.shared_guardianship_commands + where creature_id = p_creature_id + order by created_at desc + offset 100 + ); + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay',false,'rebased',v_rebased); +end; +$$; + +create or replace function public.leave_shared_guardianship( + p_creature_id uuid, + p_idempotency_key text, + p_expected_revision bigint +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_participant public.shared_guardianship_participants; + v_creature public.shared_guardianship_creatures; + v_command public.shared_guardianship_commands; + v_remaining integer; + v_result jsonb; +begin + if p_idempotency_key !~ '^[A-Za-z0-9_-]{8,120}$' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + select * into v_command from public.shared_guardianship_commands + where creature_id = p_creature_id and idempotency_key = p_idempotency_key; + if found then + if v_command.actor_user_id is distinct from v_user_id or v_command.command_kind <> 'leave' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + return v_command.result || jsonb_build_object('replay',true); + end if; + select * into v_participant from public.shared_guardianship_participants + where creature_id = p_creature_id and user_id = v_user_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_access_denied' using errcode = '42501'; end if; + select * into v_creature from public.shared_guardianship_creatures + where creature_id = p_creature_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_creature_not_found' using errcode = '22023'; end if; + if p_expected_revision is null or p_expected_revision <> v_creature.revision then + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('conflict',true,'replay',false); + end if; + select count(*)::integer into v_remaining + from public.shared_guardianship_participants + where creature_id = p_creature_id + and user_id <> v_user_id + and status = 'active'; + update public.shared_guardianship_events set actor_user_id = null + where creature_id = p_creature_id and actor_user_id = v_user_id; + update public.shared_guardianship_creatures set + revision = revision + 1, + status = case when v_remaining = 0 then 'archived' else status end, + updated_at = timezone('utc', now()) + where creature_id = p_creature_id returning * into v_creature; + insert into public.shared_guardianship_events(creature_id,actor_user_id,actor_label,idempotency_key,event_kind,summary,before_revision,after_revision) + values (p_creature_id,null,v_participant.guardian_label,'departure_' || replace(gen_random_uuid()::text,'-',''),'departure',v_participant.guardian_label || ' left Shared Guardianship.',v_creature.revision-1,v_creature.revision); + v_result := jsonb_build_object( + 'left',true, + 'sharedCreatureId',p_creature_id, + 'revision',v_creature.revision, + 'remainingGuardians',v_remaining, + 'archived',v_remaining = 0 + ); + insert into public.shared_guardianship_commands(creature_id,actor_user_id,idempotency_key,command_kind,result) + values (p_creature_id,v_user_id,p_idempotency_key,'leave',v_result); + delete from public.shared_guardianship_participants + where creature_id = p_creature_id and user_id = v_user_id; + delete from public.shared_guardianship_events + where event_id in ( + select event_id from public.shared_guardianship_events + where creature_id = p_creature_id + order by after_revision desc + offset 100 + ); + return v_result || jsonb_build_object('replay',false); +end; +$$; + +create or replace function public.cancel_shared_guardianship_invitation(p_invitation_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_invitation public.shared_guardianship_invitations; +begin + select * into v_invitation from public.shared_guardianship_invitations + where invitation_id = p_invitation_id and (host_user_id = v_user_id or guest_user_id = v_user_id) for update; + if not found then raise exception 'shared_guardianship_invitation_not_found' using errcode = '42501'; end if; + if v_invitation.status = 'cancelled' then + return public.shared_guardianship_invitation_view(v_invitation,v_user_id) + || jsonb_build_object('replay',true); + end if; + if v_invitation.status not in ('waiting','paired','ready') then raise exception 'shared_guardianship_invitation_locked' using errcode = '22023'; end if; + update public.shared_guardianship_invitations set status = 'cancelled', updated_at = timezone('utc', now()) + where invitation_id = p_invitation_id returning * into v_invitation; + return public.shared_guardianship_invitation_view(v_invitation,v_user_id) + || jsonb_build_object('replay',false); +end; +$$; + +create or replace function public.set_shared_guardianship_notifications( + p_creature_id uuid, + p_muted boolean, + p_idempotency_key text, + p_expected_revision bigint +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_participant public.shared_guardianship_participants; + v_creature public.shared_guardianship_creatures; + v_command public.shared_guardianship_commands; + v_rebased boolean := false; +begin + if p_idempotency_key !~ '^[A-Za-z0-9_-]{8,120}$' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + select * into v_participant from public.shared_guardianship_participants + where creature_id = p_creature_id and user_id = v_user_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_access_denied' using errcode = '42501'; end if; + select * into v_creature from public.shared_guardianship_creatures + where creature_id = p_creature_id and status = 'active' for update; + if not found then raise exception 'shared_guardianship_creature_not_found' using errcode = '22023'; end if; + select * into v_command from public.shared_guardianship_commands + where creature_id = p_creature_id and idempotency_key = p_idempotency_key; + if found then + if v_command.actor_user_id is distinct from v_user_id or v_command.command_kind <> 'notifications' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay',true,'rebased',false); + end if; + if p_expected_revision is null + or p_expected_revision > v_creature.revision + or p_expected_revision < v_creature.revision - 1 then + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('conflict',true,'replay',false,'rebased',false); + end if; + v_rebased := p_expected_revision = v_creature.revision - 1; + update public.shared_guardianship_participants + set notifications_muted = coalesce(p_muted, false) + where creature_id = p_creature_id and user_id = v_user_id; + update public.shared_guardianship_creatures + set revision = revision + 1, updated_at = timezone('utc', now()) + where creature_id = p_creature_id returning * into v_creature; + insert into public.shared_guardianship_commands(creature_id,actor_user_id,idempotency_key,command_kind,result) + values (p_creature_id,v_user_id,p_idempotency_key,'notifications',jsonb_build_object('acceptedRevision',v_creature.revision)); + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay',false,'rebased',v_rebased); +end; +$$; + +create or replace function public.archive_orphaned_shared_guardianship() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if not exists ( + select 1 + from public.shared_guardianship_participants + where creature_id = old.creature_id + and status = 'active' + ) then + update public.shared_guardianship_creatures + set status = 'archived', + revision = revision + 1, + updated_at = timezone('utc', now()) + where creature_id = old.creature_id + and status = 'active'; + end if; + return null; +end; +$$; + +create trigger archive_orphaned_shared_guardianship_after_participant_change +after delete or update of status on public.shared_guardianship_participants +for each row execute function public.archive_orphaned_shared_guardianship(); + +create or replace function public.purge_shared_guardianship_retention() +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_join_attempts integer; + v_invitations integer; + v_commands integer; +begin + update public.shared_guardianship_invitations + set status = 'expired', updated_at = timezone('utc', now()) + where status in ('waiting','paired','ready') + and expires_at <= timezone('utc', now()); + + delete from public.shared_guardianship_join_attempts + where attempted_at < timezone('utc', now()) - interval '24 hours'; + get diagnostics v_join_attempts = row_count; + + delete from public.shared_guardianship_invitations + where status in ('cancelled','expired','committed') + and coalesce(committed_at, updated_at) < timezone('utc', now()) - interval '30 days'; + get diagnostics v_invitations = row_count; + + delete from public.shared_guardianship_commands + where created_at < timezone('utc', now()) - interval '30 days'; + get diagnostics v_commands = row_count; + + return jsonb_build_object( + 'joinAttemptsDeleted',v_join_attempts, + 'invitationsDeleted',v_invitations, + 'commandsDeleted',v_commands + ); +end; +$$; + +revoke all on function public.shared_guardianship_user_is_permanent(uuid) from public; +revoke all on function public.shared_guardianship_require_user() from public; +revoke all on function public.shared_guardianship_user_is_eligible(uuid) from public; +revoke all on function public.shared_guardianship_require_eligible_user() from public; +revoke all on function public.shared_guardianship_invitation_view(public.shared_guardianship_invitations,uuid) from public; +revoke all on function public.archive_orphaned_shared_guardianship() from public; +revoke all on function public.purge_shared_guardianship_retention() from public; +revoke all on function public.resolve_shared_guardianship_execution(uuid,uuid) from public; +revoke all on function public.get_shared_guardianship_execution_context(uuid,text) from public; +revoke all on function public.stage_shared_guardianship_result(uuid,text,text,jsonb,jsonb) from public; +grant execute on function public.resolve_shared_guardianship_execution(uuid,uuid) to service_role; +grant execute on function public.get_shared_guardianship_execution_context(uuid,text) to service_role; +grant execute on function public.stage_shared_guardianship_result(uuid,text,text,jsonb,jsonb) to service_role; +grant execute on function public.purge_shared_guardianship_retention() to service_role; + +revoke all on function public.attest_shared_guardianship_eligibility(text,text,text) from public; +revoke all on function public.create_shared_guardianship_invitation(text,bigint,text) from public; +revoke all on function public.join_shared_guardianship_invitation(text,text,bigint) from public; +revoke all on function public.get_shared_guardianship_invitation(uuid) from public; +revoke all on function public.confirm_shared_guardianship_invitation(uuid) from public; +revoke all on function public.submit_shared_guardianship_name(uuid,text) from public; +revoke all on function public.get_shared_guardianship_projection(uuid) from public; +revoke all on function public.list_shared_guardianship_creatures() from public; +revoke all on function public.perform_shared_guardianship_care(uuid,text,text,bigint) from public; +revoke all on function public.leave_shared_guardianship(uuid,text,bigint) from public; +revoke all on function public.cancel_shared_guardianship_invitation(uuid) from public; +revoke all on function public.set_shared_guardianship_notifications(uuid,boolean,text,bigint) from public; + +grant execute on function public.attest_shared_guardianship_eligibility(text,text,text) to authenticated; +grant execute on function public.create_shared_guardianship_invitation(text,bigint,text) to authenticated; +grant execute on function public.join_shared_guardianship_invitation(text,text,bigint) to authenticated; +grant execute on function public.get_shared_guardianship_invitation(uuid) to authenticated; +grant execute on function public.confirm_shared_guardianship_invitation(uuid) to authenticated; +grant execute on function public.submit_shared_guardianship_name(uuid,text) to authenticated; +grant execute on function public.get_shared_guardianship_projection(uuid) to authenticated; +grant execute on function public.list_shared_guardianship_creatures() to authenticated; +grant execute on function public.perform_shared_guardianship_care(uuid,text,text,bigint) to authenticated; +grant execute on function public.leave_shared_guardianship(uuid,text,bigint) to authenticated; +grant execute on function public.cancel_shared_guardianship_invitation(uuid) to authenticated; +grant execute on function public.set_shared_guardianship_notifications(uuid,boolean,text,bigint) to authenticated; + +comment on table public.shared_guardianship_creatures is 'Canonical one-child Shared Guardianship state; clients receive participant-scoped projections only.'; +comment on table public.shared_guardianship_participants is 'Private access grants. Emails and account profile data are never copied here.'; +comment on table public.shared_guardianship_events is 'Bounded idempotent care history using neutral guardian labels only.'; +comment on table public.shared_guardianship_commands is 'Private bounded command receipts used for safe retries; never exposed to peers.'; diff --git a/supabase/migrations/20260831000300_rate_limit_shared_guardianship.sql b/supabase/migrations/20260831000300_rate_limit_shared_guardianship.sql new file mode 100644 index 00000000..0c147ac0 --- /dev/null +++ b/supabase/migrations/20260831000300_rate_limit_shared_guardianship.sql @@ -0,0 +1,67 @@ +-- Shared Guardianship abuse hardening. These trigger checks run in the same +-- transaction as the canonical mutation, so a rejected burst cannot partially +-- change creature state or create an invitation. + +create index if not exists shared_guardianship_invite_host_created_idx + on public.shared_guardianship_invitations(host_user_id, created_at desc); + +create index if not exists shared_guardianship_commands_actor_created_idx + on public.shared_guardianship_commands(actor_user_id, created_at desc) + where actor_user_id is not null; + +create or replace function public.enforce_shared_guardianship_invitation_rate() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + if ( + select count(*) + from public.shared_guardianship_invitations + where host_user_id = new.host_user_id + and created_at > timezone('utc', now()) - interval '1 hour' + ) >= 5 then + raise exception 'shared_guardianship_invitation_rate_limited' using errcode = '42900'; + end if; + return new; +end; +$$; + +create trigger enforce_shared_guardianship_invitation_rate_before_insert +before insert on public.shared_guardianship_invitations +for each row execute function public.enforce_shared_guardianship_invitation_rate(); + +create or replace function public.enforce_shared_guardianship_command_rate() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_limit integer := case when new.command_kind = 'care' then 30 else 10 end; +begin + -- Departure is a safety control and must never be throttled. + if new.actor_user_id is null or new.command_kind = 'leave' then + return new; + end if; + if ( + select count(*) + from public.shared_guardianship_commands + where actor_user_id = new.actor_user_id + and command_kind = new.command_kind + and created_at > timezone('utc', now()) - interval '1 minute' + ) >= v_limit then + raise exception 'shared_guardianship_action_rate_limited' using errcode = '42900'; + end if; + return new; +end; +$$; + +create trigger enforce_shared_guardianship_command_rate_before_insert +before insert on public.shared_guardianship_commands +for each row execute function public.enforce_shared_guardianship_command_rate(); + +revoke all on function public.enforce_shared_guardianship_invitation_rate() from public; +revoke all on function public.enforce_shared_guardianship_command_rate() from public; + diff --git a/supabase/migrations/20260901000100_require_guardianship_password_identity.sql b/supabase/migrations/20260901000100_require_guardianship_password_identity.sql new file mode 100644 index 00000000..c2d3c1e4 --- /dev/null +++ b/supabase/migrations/20260901000100_require_guardianship_password_identity.sql @@ -0,0 +1,97 @@ +-- Shared Guardianship requires a recoverable password credential in addition +-- to a verified email. Client metadata is intentionally not trusted because +-- users may edit their own raw_user_meta_data. + +create or replace function public.shared_guardianship_user_is_permanent(p_user_id uuid) +returns boolean +language sql +stable +security definer +set search_path = '' +as $$ + select exists ( + select 1 + from auth.users as account + where account.id = p_user_id + and account.is_anonymous is false + and account.email is not null + and account.email_confirmed_at is not null + and coalesce(account.encrypted_password, '') <> '' + ); +$$; + +create or replace function public.set_shared_guardianship_notifications( + p_creature_id uuid, + p_muted boolean, + p_idempotency_key text, + p_expected_revision bigint +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_user_id uuid := public.shared_guardianship_require_eligible_user(); + v_creature public.shared_guardianship_creatures; + v_command public.shared_guardianship_commands; + v_rebased boolean := false; +begin + if p_idempotency_key !~ '^[A-Za-z0-9_-]{8,120}$' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + if not exists ( + select 1 + from public.shared_guardianship_participants + where creature_id = p_creature_id + and user_id = v_user_id + and status = 'active' + ) then + raise exception 'shared_guardianship_access_denied' using errcode = '42501'; + end if; + select * into v_creature + from public.shared_guardianship_creatures + where creature_id = p_creature_id and status = 'active' + for update; + if not found then + raise exception 'shared_guardianship_creature_not_found' using errcode = '22023'; + end if; + select * into v_command + from public.shared_guardianship_commands + where creature_id = p_creature_id and idempotency_key = p_idempotency_key; + if found then + if v_command.actor_user_id is distinct from v_user_id + or v_command.command_kind <> 'notifications' then + raise exception 'shared_guardianship_action_invalid' using errcode = '22023'; + end if; + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay', true, 'rebased', false); + end if; + if p_expected_revision is null + or p_expected_revision > v_creature.revision + or p_expected_revision < v_creature.revision - 1 then + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('conflict', true, 'replay', false, 'rebased', false); + end if; + v_rebased := p_expected_revision = v_creature.revision - 1; + update public.shared_guardianship_participants + set notifications_muted = coalesce(p_muted, false) + where creature_id = p_creature_id and user_id = v_user_id; + update public.shared_guardianship_creatures + set revision = revision + 1, updated_at = timezone('utc', now()) + where creature_id = p_creature_id + returning * into v_creature; + insert into public.shared_guardianship_commands( + creature_id, actor_user_id, idempotency_key, command_kind, result + ) values ( + p_creature_id, v_user_id, p_idempotency_key, 'notifications', + jsonb_build_object('acceptedRevision', v_creature.revision) + ); + return public.get_shared_guardianship_projection(p_creature_id) + || jsonb_build_object('replay', false, 'rebased', v_rebased); +end; +$$; + +revoke all on function public.shared_guardianship_user_is_permanent(uuid) from public; +revoke all on function public.set_shared_guardianship_notifications(uuid,boolean,text,bigint) from public; +grant execute on function public.set_shared_guardianship_notifications(uuid,boolean,text,bigint) to authenticated; diff --git a/supabase/migrations/20260901000200_fix_guardianship_child_reservation.sql b/supabase/migrations/20260901000200_fix_guardianship_child_reservation.sql new file mode 100644 index 00000000..cc78aa91 --- /dev/null +++ b/supabase/migrations/20260901000200_fix_guardianship_child_reservation.sql @@ -0,0 +1,62 @@ +-- A child UUID is reserved when both guardians confirm, before the generated +-- creature is committed after naming. The original FK was therefore too early: +-- it required the child row during the confirmation transaction. + +alter table public.shared_guardianship_invitations + drop constraint if exists shared_guardianship_invitation_child_fk; + +create or replace function public.assert_committed_guardianship_child_exists() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + if new.status = 'committed' + and new.child_id is not null + and not exists ( + select 1 + from public.shared_guardianship_creatures creature + where creature.creature_id = new.child_id + ) then + raise exception 'shared_guardianship_committed_child_missing' + using errcode = '23503'; + end if; + return new; +end; +$$; + +drop trigger if exists assert_committed_guardianship_child_exists + on public.shared_guardianship_invitations; + +create constraint trigger assert_committed_guardianship_child_exists +after insert or update of status, child_id +on public.shared_guardianship_invitations +deferrable initially deferred +for each row execute function public.assert_committed_guardianship_child_exists(); + +create or replace function public.clear_guardianship_child_reservation_on_delete() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + update public.shared_guardianship_invitations + set child_id = null, + updated_at = timezone('utc', now()) + where child_id = old.creature_id; + return old; +end; +$$; + +drop trigger if exists clear_guardianship_child_reservation_on_delete + on public.shared_guardianship_creatures; + +create trigger clear_guardianship_child_reservation_on_delete +before delete on public.shared_guardianship_creatures +for each row execute function public.clear_guardianship_child_reservation_on_delete(); + +revoke all on function public.assert_committed_guardianship_child_exists() from public; +revoke all on function public.assert_committed_guardianship_child_exists() from anon, authenticated; +revoke all on function public.clear_guardianship_child_reservation_on_delete() from public; +revoke all on function public.clear_guardianship_child_reservation_on_delete() from anon, authenticated; + diff --git a/supabase/migrations/20260901000300_secure_guardianship_reservation_triggers.sql b/supabase/migrations/20260901000300_secure_guardianship_reservation_triggers.sql new file mode 100644 index 00000000..8d37dad2 --- /dev/null +++ b/supabase/migrations/20260901000300_secure_guardianship_reservation_triggers.sql @@ -0,0 +1,10 @@ +-- Reservation integrity triggers touch private canonical tables while player +-- commands run as authenticated users. Execute only these fixed trigger bodies +-- with owner rights; direct table access remains revoked. + +alter function public.assert_committed_guardianship_child_exists() + security definer; + +alter function public.clear_guardianship_child_reservation_on_delete() + security definer; +