diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index b6c15a2..9126330 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -128,13 +128,38 @@ jobs: - name: Build API run: go build -v ./cmd/api + # =========================================== + # Functions: Test (Jest) + # =========================================== + functions-test: + name: Functions - Test (Jest) + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./functions + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: functions/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + # =========================================== # Deploy: Firebase Hosting (finalwishes-prod) # =========================================== deploy-hosting: name: Deploy - Firebase Hosting runs-on: ubuntu-latest - needs: [web-build, api-check] + needs: [web-build, api-check, functions-test] if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 @@ -186,7 +211,7 @@ jobs: deploy-api: name: Deploy - API to Cloud Run runs-on: ubuntu-latest - needs: [api-check] + needs: [api-check, functions-test] if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 @@ -226,7 +251,7 @@ jobs: deploy-functions: name: Deploy - Firebase Functions runs-on: ubuntu-latest - needs: [web-build, api-check] + needs: [web-build, api-check, functions-test] if: github.ref == 'refs/heads/main' && github.event_name == 'push' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index d9e4344..a615fee 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -70,12 +70,38 @@ jobs: - name: Run tests run: npm test + # =========================================== + # Functions: Test (Jest) + # =========================================== + functions-test: + name: Functions - Test (Jest) + if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./functions + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: functions/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test + # =========================================== # Web: Build & Deploy Preview # =========================================== build_and_preview: name: Web - Build & Preview - needs: [api-check, web-test] + needs: [api-check, web-test, functions-test] if: ${{ github.event.pull_request.head.repo.full_name == github.repository }} runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9194f77..cec5852 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Sem - **Shared-services signing provider — consume Sirsi first, dissociated fallback (ADR-047)** (`api/internal/opensign/provider.go`, `handler.go`, `webhook.go`) — the OpenSign create flow now goes through a `SigningProvider` that CONSUMES THE SIRSI SIGN SERVICE FIRST (`SERVICES_REGISTRY` endpoint, Bearer/ADR-006 HMAC, tenant-attributed) and falls back to dissociated/self-hosted infra ONLY on a Sirsi-org **availability** failure (transport/timeout/5xx) — never on a clean business rejection (4xx), which is surfaced. Each result records `ServedBy` for consumption observability. Realizes the owner's shared-services model (tenants buy Sirsi services; resilient to Sirsi-org outages) portfolio-wide. The server-side envelope→directive binding + fail-closed webhook are unchanged. Config via `SIRSI_SIGN_*` (primary) + `OPENSIGN_*` (fallback) env/secrets. - **Google Photos import — frontend flow (CR-12, ADR-045)** (`web/src/lib/google-photos-import.ts`, `web/src/routes/estates.$estateId.heirlooms.tsx`) — wires the existing backend Picker routes (`/api/v1/heirlooms/{estateId}/google-photos/*`) to the UI: an "Import from Google Photos" button on the Heirloom Registry obtains a Picker-scoped Google OAuth token via Google Identity Services (works for email/password users — no Firebase session disruption), creates a picking session, opens Google's own picker, polls until the user finishes, then triggers the server-side import (download + de-dup + store as heirlooms). Realtime list shows imports automatically. GIS load retries on script error + a 90s token timeout guards a dismissed popup. **OWNER PREREQUISITES (build-time/infra):** enable the Google Photos Picker API on `finalwishes-prod`, add `…/auth/photospicker.mediaitems.readonly` to the OAuth consent screen, and set `VITE_GOOGLE_OAUTH_CLIENT_ID`. Until configured, the button reports "not configured." Full end-to-end verification requires those prereqs. - **Legal RAG corpus ingestion pipeline (CR-10, ADR-044)** (`api/cmd/corpus-ingest`, `api/internal/guidance/rag.go`, `docs/legal-corpus/manifest.md`) — an idempotent CLI that loads the Shepherd's legal corpus (`legal_corpus_sources` + `legal_corpus_chunks`, pgvector) from a manifest of **owner-verified, verbatim** statute text. Chunks on blank-line paragraph boundaries (≤~2000 chars, never mid-sentence), embeds each chunk with `gemini-embedding-001`/`RETRIEVAL_DOCUMENT` (new `EmbedDocument` — asymmetric to the retriever's `RETRIEVAL_QUERY`), and upserts by id. `-dry-run` parses+chunks with no DB/Vertex. The pipeline NEVER generates/paraphrases/truncates legal text (Rule 9); engineering owns the pipeline, owner/legal owns sourcing the verified text (IL/MD/MN launch statutes). Built + vetted + dry-run verified; corpus population + the CR-10 evidence run remain the owner/data step. +- **CI now runs the Cloud Functions Jest suite** (`.github/workflows/firebase-hosting-pull-request.yml`, `.github/workflows/firebase-hosting-merge.yml`) — added a `functions-test` job (ubuntu-latest, Node 22, `working-directory: ./functions`, `npm ci` → `npm test`) to both PR and merge workflows. Previously `functions/index.test.js` (18 tests covering `autoMatchInvitation` backfill, `sendMail` recipient validation, MIME header-injection guards, Guardian inactivity) ran in neither CI workflow and could silently rot while functions were still deployed. The job now gates the PR aggregate (`build_and_preview`) alongside `api-check`/`web-test`, and gates all three merge deploys (`deploy-hosting`, `deploy-api`, `deploy-functions`) so no release ships with red functions tests. Closes the deferred residual from the completion wave (the fix-bucket globs excluded workflow YAML). ### Changed +- **OpenSign signer is now the estate PRINCIPAL, not the caller — with a verified-email gate (ADR-047; claude-home signer=principal decision 2026-06-14)** (`api/internal/opensign/handler.go`, `signer.go`, `webhook.go`, `cmd/api/main.go`) — a legal directive/POA must be signed BY the estate principal whose estate it governs; an executor/admin only INITIATES the ceremony. `HandleCreateEnvelope` no longer takes the signer identity from the authenticated caller's token claims. It now resolves the signer SERVER-SIDE from `estates/{id}.principalId` → Firebase Auth (verified email + display name), and **REJECTS with HTTP 403 if the principal's email is not verified** (signing cannot proceed against an unverified address). Missing/empty `principalId` → 400; auth client unavailable → 503; Firebase lookup failure → 502. The caller is recorded only as `initiatedBy` in the server-only `signing_envelopes` audit record (`createdBy` retained for back-compat; `signerEmail` now persisted). `NewWebhookHandler` gains an `*firebaseAuth.Client` param (threaded from `main.go`, declared at outer scope so it is in scope at all 3 call sites). Resolution is fronted by a small `signerResolver` interface (production `firebaseSignerResolver` backed by Firestore+Firebase Auth; a fake in tests) — the auth client cannot be constructed offline. This supersedes the prior "signer = authenticated caller" behavior. `provider.go` untouched. - **Soul Log per-recipient narrowing — `sharedWith` UIDs (ADR-046 #1 residual closed)** (`firestore.rules`, `firestore.indexes.json`, `web/src/routes/estates.$estateId.soul-log.lazy.tsx`, `web/src/lib/firestore.ts`, `functions/index.js`, `scripts/migrate-soullog-sharedwith.js`) — a non-owner could read EVERY `shared` Soul Log entry (incl. other heirs'); now a non-owner reads only entries shared WITH THEM. Entries carry `sharedWith` (array of heir UIDs); the read rule + non-owner query gate on `request.auth.uid in resource.data.sharedWith` (array-contains, new composite index). The composer resolves tagged heir names→UIDs at save; `autoMatchInvitation` backfills a heir's UID into `sharedWith` of entries tagged with their name when they accept (so pre-registration sharing resolves). Ships an idempotent DRY-RUN-by-default migration for existing entries (`scripts/migrate-soullog-sharedwith.js` — owner runs `--apply` AFTER review). **Migration must run before the rule deploys, as the rule/query is breaking for un-migrated entries.** ### Fixed +- **PR test contract drift — email-only invitations and settings MFA access** (`web/src/lib/invitations.test.ts`, `web/src/lib/persona.test.ts`, `web/src/components/guards/RoleGuard.test.ts`) — aligned Vitest expectations with the documented production contract: invitations remain email-only until a live SMS provider/function exists, so phone input from older callers is ignored rather than persisted or queued; fiduciary/heir access to `settings` remains allowed for MFA/profile security while protected estate surfaces stay blocked. - **HIGH — OpenSign signing ceremony now estate-bound (H1 create-side closed; Assiduous pattern)** (`api/internal/opensign/handler.go`, `webhook.go`, `cmd/api/main.go`, `firestore.rules`, `web/src/routes/estates.$estateId.directives.lazy.tsx`) — `CreateEnvelopeHandler` was a standalone func that took `signerEmail`/templateId from the body with **no estate binding**, and the webhook stamped the verified result onto **whatever directive matched the envelopeId across ALL estates** (a client-writable field). Adopted the proven Assiduous `opensign.Service` pattern: the create handler is now a method on the fs-bearing handler, requires `estateId`+`directiveId`, verifies estate **writer** access, and records a **server-only `signing_envelopes/{envelopeId}` → (estate,directive) mapping**; `handleSigningCompleted`/`handleSigningDeclined`/status-poll resolve that mapping with a direct GET and update **only the bound directive** — the signing evidence chain can no longer be redirected to another estate's directive. The signer identity is now forced to the AUTHENTICATED caller (token email claim) — a writer can no longer name an arbitrary signerEmail (claude-home PR #4 review). (The unauthenticated webhook **forge** was already closed separately.) Added `auth.ContextWithUserID` test helper. ### Added diff --git a/api/cmd/api/main.go b/api/cmd/api/main.go index d02eefe..989f1c3 100644 --- a/api/cmd/api/main.go +++ b/api/cmd/api/main.go @@ -12,6 +12,7 @@ import ( "cloud.google.com/go/firestore" "cloud.google.com/go/storage" firebase "firebase.google.com/go/v4" + firebaseAuth "firebase.google.com/go/v4/auth" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" @@ -200,6 +201,12 @@ func main() { // Initialize Firebase Admin for auth token verification var authMiddleware func(http.Handler) http.Handler + // authClient is also consumed by the OpenSign create-envelope handler to resolve the + // estate PRINCIPAL's verified identity (the legal signer is the principal, never the + // caller — claude-home signer=principal decision 2026-06-14). Declared at the outer + // scope so it is in scope at the NewWebhookHandler call sites below; stays nil in + // local dev with no Firebase Auth. + var authClient *firebaseAuth.Client firebaseApp, err := firebase.NewApp(ctx, nil) if err != nil { @@ -210,7 +217,7 @@ func main() { log.Warn().Err(err).Msg("Firebase Admin SDK initialization failed — auth middleware disabled (local dev only)") authMiddleware = func(next http.Handler) http.Handler { return next } } else { - authClient, err := firebaseApp.Auth(ctx) + ac, err := firebaseApp.Auth(ctx) if err != nil { if projectID != "" { log.Fatal().Err(err).Msg("Firebase Auth client initialization failed in production mode") @@ -218,6 +225,7 @@ func main() { log.Warn().Err(err).Msg("Firebase Auth client initialization failed — auth middleware disabled (local dev only)") authMiddleware = func(next http.Handler) http.Handler { return next } } else { + authClient = ac log.Info().Msg("Firebase Admin Auth initialized — token verification active") authMiddleware = auth.Middleware(authClient) } @@ -335,7 +343,7 @@ func main() { r.Route("/api/v1", func(r chi.Router) { r.Use(authMiddleware) r.Route("/opensign", func(r chi.Router) { - oh := opensign.NewWebhookHandler(fs) + oh := opensign.NewWebhookHandler(fs, authClient) r.Post("/create-envelope", oh.HandleCreateEnvelope) if fs != nil { r.Get("/status", oh.HandleCheckSigningStatus) @@ -344,13 +352,13 @@ func main() { }) // OpenSign webhook — no auth, uses webhook signature verification if fs != nil { - webhookHandler := opensign.NewWebhookHandler(fs) + webhookHandler := opensign.NewWebhookHandler(fs, authClient) r.Post("/api/v1/opensign/webhook", webhookHandler.HandleWebhook) } r.Group(func(r chi.Router) { r.Use(authMiddleware) - r.Post("/api/envelopes", opensign.NewWebhookHandler(fs).HandleCreateEnvelope) + r.Post("/api/envelopes", opensign.NewWebhookHandler(fs, authClient).HandleCreateEnvelope) }) // Guidance routes (The Shepherd v3 — Claude Opus via sirsi-ai, Genkit fallback) @@ -473,7 +481,6 @@ func main() { r.Post("/death-cert/confirm", probateHandler.HandleConfirmDeathCert) r.Get("/death-cert", probateHandler.HandleGetDeathCertFacts) r.Get("/forms", probateHandler.HandleGetFormTemplates) - r.Get("/forms/data", probateHandler.HandleGetFormData) r.Get("/executor/status", probateHandler.HandleGetExecutorStatus) r.Post("/executor/confirm", probateHandler.HandleConfirmExecutorRole) r.Get("/advance-directives", probateHandler.HandleGetAdvanceDirectives) diff --git a/api/internal/forms/maps/il_small_estate_3606.go b/api/internal/forms/maps/il_small_estate_3606.go index add7c64..b4d4b4e 100644 --- a/api/internal/forms/maps/il_small_estate_3606.go +++ b/api/internal/forms/maps/il_small_estate_3606.go @@ -5,23 +5,32 @@ import "github.com/sirsi-technologies/finalwishes-api/internal/forms" // SmallEstateAffidavit3606 returns the coordinate map for the Illinois Small // Estate Affidavit (SOS/Probate Form 3606, rev. 1/26), 755 ILCS 5/25-1. // -// The blank is flat (no AcroForm), 4 pages, US Letter. This first-pass map -// covers the core single-value affidavit fields (affiant + decedent identity, -// dates, addresses, relationship) and flags the execution block on page 4 -// (signature, date, notary) as Execution=true — never stamped, wet-sign + -// notarization required. +// The blank is flat (no AcroForm), 4 pages, US Letter. This map covers: +// - the core single-value affidavit fields (affiant + decedent identity, +// dates, addresses, relationship); +// - the single-claimant rows of the variable-length schedules — the first +// creditor row (para 6/7), the first heir/legatee row (para 9), the total +// personal-property valuation, and the spousal/child award amount — which +// cover the common one-creditor / one-heir estate without a repeating-row +// renderer; +// - the execution block on page 4 (signature, date, notary), flagged +// Execution=true — never stamped, wet-sign + notarization required. // -// NOT YET MAPPED (documented limitation — needs a repeating-row renderer, a -// future engine extension): the variable-length schedules — creditor Classes -// 1–7 (paras 6–7), heirs/legatees tables (paras 9–10), and the spousal/child -// award computations. Those rows vary per estate and cannot be expressed as -// fixed coordinates. Single-claimant common cases can be added as discrete -// fields later; multi-row support is tracked separately. +// MULTI-ROW LIMITATION (tracked, not hidden): estates with MORE THAN ONE +// creditor in any of Classes 1–7 (paras 6–7) or MORE THAN ONE heir/legatee +// (paras 9–10) need a repeating-row renderer — a future forms-engine extension. +// Until that lands, multi-claimant estates must complete the extra rows by hand +// on the printed draft; the prefill stamps only the first row of each schedule. +// The fill engine's missing-required reporting plus the PREPARATION-ASSISTANCE +// disclaimer keep this Rule-9 honest: nothing is asserted that was not supplied. // -// Coordinates are derived from positioned-text extraction (pdftotext -bbox) and -// marked ConfidenceLow: the affidavit uses dotted/underscore leaders, so the -// exact blank-start on each line is an estimate to be tuned against the proof -// raster before this form is treated as production-ready. +// PRODUCTION READINESS: every coordinate below is marked ConfidenceLow. The +// affidavit uses dotted/underscore leaders, so each blank-start X/Y is an +// estimate derived from positioned-text extraction (pdftotext -bbox) that MUST +// be tuned against the proof raster (docs/forms-phase0/proof/) before this form +// is offered as a final draft. Callers should treat il_small_estate_3606 as a +// PREVIEW form — surface it behind a preview affordance, not as GA — until a +// proof-raster pass promotes these coordinates to ConfidenceHigh. func SmallEstateAffidavit3606() *forms.CoordinateMap { return &forms.CoordinateMap{ FormID: "il_small_estate_3606", @@ -79,6 +88,71 @@ func SmallEstateAffidavit3606() *forms.CoordinateMap { Note: "After \"before their death was\" (x315), yTop 309.81.", }, + // --- Page 2: personal-property valuation + first creditor row --- + // + // Single-claimant subset of the variable-length schedules. The first + // row of each schedule is mapped here; additional rows require the + // repeating-row renderer (documented limitation above) and are + // completed by hand on the printed draft. + { + Key: "total_personal_property", Label: "Total value of decedent's personal estate (para 5)", + Kind: forms.FieldText, Page: 2, + X: 360, Y: 700.0, MaxWidth: 160, + Confidence: forms.ConfidenceLow, + Note: "After \"does not exceed\" / value rule on para 5. Personal property only — real estate excluded per 755 ILCS 5/25-1. ESTIMATE: tune X/Y against proof raster.", + }, + { + Key: "creditor1_name", Label: "First creditor — name (Classes 1–7, paras 6–7, row 1)", + Kind: forms.FieldText, Page: 2, + X: 70, Y: 470.0, MaxWidth: 240, + Confidence: forms.ConfidenceLow, + Note: "Row 1 of the creditor schedule. Multi-creditor estates need the repeating-row renderer. ESTIMATE: tune against proof raster.", + }, + { + Key: "creditor1_class", Label: "First creditor — statutory class 1–7 (paras 6–7, row 1)", + Kind: forms.FieldText, Page: 2, + X: 320, Y: 470.0, MaxWidth: 60, + Confidence: forms.ConfidenceLow, + Note: "Class column for creditor row 1. ESTIMATE: tune against proof raster.", + }, + { + Key: "creditor1_amount", Label: "First creditor — claim amount (paras 6–7, row 1)", + Kind: forms.FieldText, Page: 2, + X: 400, Y: 470.0, MaxWidth: 120, + Confidence: forms.ConfidenceLow, + Note: "Amount column for creditor row 1. ESTIMATE: tune against proof raster.", + }, + + // --- Page 3: first heir/legatee row + award computation --- + { + Key: "heir1_name", Label: "First heir/legatee — name (paras 9–10, row 1)", + Kind: forms.FieldText, Page: 3, + X: 70, Y: 520.0, MaxWidth: 220, + Confidence: forms.ConfidenceLow, + Note: "Row 1 of the heirs/legatees schedule. Multi-heir estates need the repeating-row renderer. ESTIMATE: tune against proof raster.", + }, + { + Key: "heir1_relationship", Label: "First heir/legatee — relationship to decedent (paras 9–10, row 1)", + Kind: forms.FieldText, Page: 3, + X: 300, Y: 520.0, MaxWidth: 130, + Confidence: forms.ConfidenceLow, + Note: "Relationship column for heir row 1. ESTIMATE: tune against proof raster.", + }, + { + Key: "heir1_share", Label: "First heir/legatee — share/interest (paras 9–10, row 1)", + Kind: forms.FieldText, Page: 3, + X: 440, Y: 520.0, MaxWidth: 90, + Confidence: forms.ConfidenceLow, + Note: "Share column for heir row 1 (e.g. \"100%\", \"1/2\"). ESTIMATE: tune against proof raster.", + }, + { + Key: "award_amount", Label: "Spousal/child award amount (computed, single-claimant)", + Kind: forms.FieldText, Page: 3, + X: 360, Y: 300.0, MaxWidth: 140, + Confidence: forms.ConfidenceLow, + Note: "Surviving-spouse or child award line. Computation supplied by caller; engine never derives it. ESTIMATE: tune against proof raster.", + }, + // --- Page 4: affiant relationship + execution block --- { Key: "affiant_relationship", Label: "Affiant relationship to decedent (para 10.3)", diff --git a/api/internal/googlephotos/handler.go b/api/internal/googlephotos/handler.go index 5448682..6193d84 100644 --- a/api/internal/googlephotos/handler.go +++ b/api/internal/googlephotos/handler.go @@ -14,7 +14,8 @@ import ( "cloud.google.com/go/storage" "github.com/go-chi/chi/v5" "github.com/rs/zerolog/log" - "google.golang.org/api/iterator" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/sirsi-technologies/finalwishes-api/internal/auth" ) @@ -28,9 +29,26 @@ type Handler struct { bucket string } +// defaultVaultBucket is the dev/default vault bucket name. It MUST stay in sync +// with the default used by the rest of the vault/storage pipeline (vault, +// docintell, certmail) so that imported Google Photos heirlooms land in the +// exact same bucket the signed-URL download path later reads from. If a deploy +// configures a non-default prod bucket, the caller (cmd/api) must pass it +// explicitly — falling back to this literal under a non-default prod config +// would orphan imported photos in a bucket nothing else reads. +const defaultVaultBucket = "finalwishes-vault" + func NewHandler(fs *firestore.Client, sc *storage.Client, picker *PickerClient, bucket string) *Handler { if bucket == "" { - bucket = "finalwishes-vault" + // A blank bucket means the vault-bucket env var resolved to empty at + // startup. Surface it loudly: a silent literal substitution here is the + // exact mechanism by which imports get written to a bucket the download + // path never reads. The literal keeps dev working, but the warning makes + // a prod misconfiguration visible instead of invisibly orphaning photos. + log.Warn(). + Str("fallback_bucket", defaultVaultBucket). + Msg("Google Photos handler received empty vault bucket; falling back to default — verify the vault-bucket env var matches the rest of the vault pipeline in prod") + bucket = defaultVaultBucket } return &Handler{fs: fs, sc: sc, picker: picker, bucket: bucket} } @@ -216,10 +234,11 @@ func (h *Handler) hasHash(ctx context.Context, estateID, sha string) (bool, erro if err == nil { return true, nil } - if iterator.Done == err { - return false, nil - } - if strings.Contains(err.Error(), "NotFound") || strings.Contains(err.Error(), "not found") { + // A Firestore single-doc Get on a missing document returns a gRPC NotFound + // status — this is the canonical "no duplicate" signal (the iterator.Done + // sentinel only ever comes from iterator.Next(), never Doc().Get()). Match + // the repo convention used in payments/handlers.go (status.Code(err)==...). + if status.Code(err) == codes.NotFound { return false, nil } // Any other error (transient, permission, config) is NOT "no duplicate" — diff --git a/api/internal/opensign/handler.go b/api/internal/opensign/handler.go index dfd98da..79bbf1e 100644 --- a/api/internal/opensign/handler.go +++ b/api/internal/opensign/handler.go @@ -68,22 +68,21 @@ func (h *WebhookHandler) HandleCreateEnvelope(w http.ResponseWriter, r *http.Req } } - // Force the signer identity to the AUTHENTICATED caller — never the body's - // signerEmail. Otherwise an estate writer could name an ARBITRARY signer (send the - // signing link to anyone / forge "X signed this directive"). The verified email - // claim is the only trustworthy signer identity. (claude-home PR #4 review.) - signerEmail := "" - signerName := req.SignerName - if tok := auth.TokenFromContext(ctx); tok != nil { - if e, _ := tok.Claims["email"].(string); e != "" { - signerEmail = e - } - if n, _ := tok.Claims["name"].(string); n != "" { - signerName = n - } + // Resolve the signer as the estate PRINCIPAL — NOT the authenticated caller. + // A legal directive / POA must be signed BY the principal whose estate it governs; + // an executor or admin merely INITIATES the ceremony on the principal's behalf. So + // the OpenSign signer identity is resolved server-side from estates/{id}.principalId + // → Firebase Auth (verified email/name), and the principal's email MUST be verified + // before signing can proceed. The caller is recorded only as initiatedBy in the + // audit record below. This supersedes the prior "signer = authenticated caller" + // behavior. (claude-home signer=principal decision 2026-06-14; Refs ADR-047.) + resolver := h.signerResolver + if resolver == nil { + resolver = &firebaseSignerResolver{fs: h.fs, authClient: h.authClient} } - if signerEmail == "" { - http.Error(w, "Signer email could not be determined from your account", http.StatusBadRequest) + signerEmail, signerName, status, clientMsg := resolver.resolveSigner(ctx, req.EstateID, req.SignerName) + if status != 0 { + http.Error(w, clientMsg, status) return } @@ -124,7 +123,12 @@ func (h *WebhookHandler) HandleCreateEnvelope(w http.ResponseWriter, r *http.Req "envelopeId": envelopeID, "estateId": req.EstateID, "directiveId": req.DirectiveID, + // createdBy kept for back-compat; initiatedBy is the authoritative audit + // field — the caller INITIATED the ceremony while the estate PRINCIPAL is + // the signer (claude-home signer=principal decision 2026-06-14). "createdBy": userID, + "initiatedBy": userID, + "signerEmail": signerEmail, "status": "sent", "createdAt": firestore.ServerTimestamp, }); e != nil { diff --git a/api/internal/opensign/handler_test.go b/api/internal/opensign/handler_test.go index c992158..122005a 100644 --- a/api/internal/opensign/handler_test.go +++ b/api/internal/opensign/handler_test.go @@ -1,6 +1,7 @@ package opensign import ( + "context" "encoding/json" "io" "net/http" @@ -13,11 +14,13 @@ import ( "github.com/sirsi-technologies/finalwishes-api/internal/auth" ) -// withAuth injects an authenticated user context + token (as the middleware would) so -// these tests exercise HandleCreateEnvelope past its auth gate and the token-derived -// signer identity. A nil-fs WebhookHandler skips the estate_users check + the binding -// write, leaving the OpenSign proxy behavior under test. Bodies include -// estateId/directiveId (now required). +// withAuth injects an authenticated CALLER context (as the middleware would) so these +// tests exercise HandleCreateEnvelope past its auth gate. NOTE: as of the +// signer=principal change (claude-home 2026-06-14) the caller's token email is NO +// LONGER the signer — the signer is resolved from the estate principal via the injected +// signerResolver. The caller is recorded only as initiatedBy. A nil-fs WebhookHandler +// skips the estate_users check + the binding write, leaving the OpenSign proxy behavior +// + principal-resolution under test. Bodies include estateId/directiveId (required). func withAuth(req *http.Request) *http.Request { ctx := auth.ContextWithUserID(req.Context(), "u1") ctx = auth.ContextWithToken(ctx, &firebaseAuth.Token{Claims: map[string]interface{}{ @@ -27,7 +30,35 @@ func withAuth(req *http.Request) *http.Request { return req.WithContext(ctx) } -func openSignTestHandler() *WebhookHandler { return &WebhookHandler{} } +// fakeSignerResolver is the test double for the principal-resolution seam, standing in +// for the Firestore+Firebase-Auth-backed firebaseSignerResolver (neither can be built +// offline). It returns canned signer identity / rejection. +type fakeSignerResolver struct { + email string + name string + status int + clientMsg string +} + +func (f *fakeSignerResolver) resolveSigner(_ context.Context, _, fallbackName string) (string, string, int, string) { + if f.status != 0 { + return "", "", f.status, f.clientMsg + } + name := f.name + if name == "" { + name = fallbackName + } + return f.email, name, 0, "" +} + +// openSignTestHandler returns a handler whose principal resolves to a VERIFIED estate +// principal (principal@example.com) — the common case for the provider-proxy tests, +// which assert behavior AFTER signer resolution succeeds. +func openSignTestHandler() *WebhookHandler { + return &WebhookHandler{ + signerResolver: &fakeSignerResolver{email: "principal@example.com", name: "Estate Principal"}, + } +} // TestCreateEnvelope_InvalidJSON verifies that malformed request bodies are rejected. func TestCreateEnvelope_InvalidJSON(t *testing.T) { @@ -115,6 +146,18 @@ func TestCreateEnvelope_HappyPath(t *testing.T) { if payload["template_id"] != "t1" { t.Errorf("expected template_id t1, got %v", payload["template_id"]) } + // Signer is the estate PRINCIPAL resolved server-side — NOT the caller token + // (u1@example.com) and NOT the body's signerEmail (cylton@sirsi.ai). The + // dissociated provider nests the signer under signers[0]. + gotSigner := "" + if signers, ok := payload["signers"].([]interface{}); ok && len(signers) > 0 { + if s0, ok := signers[0].(map[string]interface{}); ok { + gotSigner, _ = s0["email"].(string) + } + } + if gotSigner != "principal@example.com" { + t.Errorf("expected signer to be the estate principal, got %q", gotSigner) + } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) @@ -277,3 +320,102 @@ func TestCreateEnvelope_NoAPIKeyStillSends(t *testing.T) { t.Errorf("expected no auth header when API key is empty, got %q", receivedAuthHeader) } } + +// TestCreateEnvelope_SignerIsVerifiedPrincipal verifies the resolved signer is the +// estate principal (verified), not the caller and not the body's signerEmail. +func TestCreateEnvelope_SignerIsVerifiedPrincipal(t *testing.T) { + var gotSignerEmail string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&payload) + if signers, ok := payload["signers"].([]interface{}); ok && len(signers) > 0 { + if s0, ok := signers[0].(map[string]interface{}); ok { + gotSignerEmail, _ = s0["email"].(string) + } + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"id":"env-1","url":"https://sign.example.com/env-1"}`) + })) + defer upstream.Close() + + t.Setenv("OPENSIGN_CREATE_ENVELOPE_URL", upstream.URL) + t.Setenv("OPENSIGN_API_KEY", "k") + t.Setenv("OPENSIGN_API_URL", "") + + h := &WebhookHandler{ + signerResolver: &fakeSignerResolver{email: "principal@example.com", name: "Estate Principal"}, + } + + // Caller token says u1@example.com; body says imposter@evil.com — both must be ignored. + body := `{"estateId":"e1","directiveId":"d1","templateId":"t1","signerName":"Imposter","signerEmail":"imposter@evil.com","redirectUrl":"https://x.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/envelopes", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.HandleCreateEnvelope(rr, withAuth(req)) + + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (%s)", rr.Code, rr.Body.String()) + } + if gotSignerEmail != "principal@example.com" { + t.Errorf("signer must be the verified estate principal, got %q", gotSignerEmail) + } +} + +// TestCreateEnvelope_PrincipalEmailUnverified verifies the required gate: signing is +// rejected with 403 when the estate principal's email is not verified. +func TestCreateEnvelope_PrincipalEmailUnverified(t *testing.T) { + h := &WebhookHandler{ + signerResolver: &fakeSignerResolver{ + status: http.StatusForbidden, + clientMsg: "The estate principal's email is not verified; signing cannot proceed", + }, + } + + body := `{"estateId":"e1","directiveId":"d1","templateId":"t1","redirectUrl":"https://x.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/envelopes", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.HandleCreateEnvelope(rr, withAuth(req)) + + if rr.Code != http.StatusForbidden { + t.Fatalf("expected 403 for unverified principal, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "not verified") { + t.Errorf("expected unverified-principal message, got %q", rr.Body.String()) + } +} + +// TestCreateEnvelope_MissingPrincipal verifies a 400 when the estate has no principal +// on record (e.g. estates/{id}.principalId is empty/missing). +func TestCreateEnvelope_MissingPrincipal(t *testing.T) { + h := &WebhookHandler{ + signerResolver: &fakeSignerResolver{ + status: http.StatusBadRequest, + clientMsg: "Estate has no principal on record", + }, + } + + body := `{"estateId":"e1","directiveId":"d1","templateId":"t1","redirectUrl":"https://x.com"}` + req := httptest.NewRequest(http.MethodPost, "/api/envelopes", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + h.HandleCreateEnvelope(rr, withAuth(req)) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for missing principal, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "no principal on record") { + t.Errorf("expected missing-principal message, got %q", rr.Body.String()) + } +} + +// TestFirebaseSignerResolver_UnverifiedAndMissing covers the production resolver's +// pure-logic branches that don't need a live Firestore client: nil fs / nil auth. +func TestFirebaseSignerResolver_DefensiveNils(t *testing.T) { + r := &firebaseSignerResolver{} // nil fs, nil authClient + _, _, status, msg := r.resolveSigner(context.Background(), "e1", "") + if status != http.StatusServiceUnavailable { + t.Errorf("nil fs should yield 503, got %d (%s)", status, msg) + } +} diff --git a/api/internal/opensign/signer.go b/api/internal/opensign/signer.go new file mode 100644 index 0000000..6f1e696 --- /dev/null +++ b/api/internal/opensign/signer.go @@ -0,0 +1,77 @@ +package opensign + +import ( + "context" + "net/http" + + "cloud.google.com/go/firestore" + "github.com/rs/zerolog/log" +) + +// signerResolver resolves the legally-required signer identity (the estate PRINCIPAL) +// for an envelope. A non-zero httpStatus signals rejection and clientMsg is the +// safe-to-surface error; httpStatus == 0 means email/name are populated and verified. +// +// WHY this is a seam: the signer of a legal directive/POA must be the estate principal, +// never the authenticated caller (an executor/admin only initiates). Resolution needs a +// live Firestore client (estates/{id}.principalId) + Firebase Auth (verified email), +// neither of which can be constructed offline — so tests inject a fake resolver while +// production uses firebaseSignerResolver. (claude-home signer=principal decision +// 2026-06-14; Refs ADR-047.) +type signerResolver interface { + resolveSigner(ctx context.Context, estateID, fallbackName string) (email, name string, httpStatus int, clientMsg string) +} + +// firebaseSignerResolver resolves the principal from estates/{id}.principalId via +// Firestore, then loads the principal's verified identity from Firebase Auth. +type firebaseSignerResolver struct { + fs *firestore.Client + authClient userLookup +} + +func (r *firebaseSignerResolver) resolveSigner(ctx context.Context, estateID, fallbackName string) (string, string, int, string) { + // The estate record holds principalId (the principal's Firebase UID) — written at + // estate creation (web/src/lib/estate-actions.ts createEstate). + if r.fs == nil { + return "", "", http.StatusServiceUnavailable, "Signer identity service unavailable" + } + estateSnap, err := r.fs.Collection("estates").Doc(estateID).Get(ctx) + if err != nil || estateSnap == nil || !estateSnap.Exists() { + return "", "", http.StatusBadRequest, "Estate has no principal on record" + } + principalID, _ := estateSnap.Data()["principalId"].(string) + if principalID == "" { + return "", "", http.StatusBadRequest, "Estate has no principal on record" + } + + // Defensive: in production the auth client is always wired; locally it may be nil. + if r.authClient == nil { + return "", "", http.StatusServiceUnavailable, "Signer identity service unavailable" + } + + principal, err := r.authClient.GetUser(ctx, principalID) + if err != nil || principal == nil { + log.Error().Err(err).Str("estate_id", estateID).Msg("Could not resolve estate principal from Firebase Auth") + return "", "", http.StatusBadGateway, "Could not resolve estate principal" + } + + // The required gate: a directive cannot be signed against an unverified principal + // email — that would let an unverified/unowned address be named as the legal signer. + if !principal.EmailVerified { + return "", "", http.StatusForbidden, "The estate principal's email is not verified; signing cannot proceed" + } + + signerEmail := principal.Email + if signerEmail == "" { + return "", "", http.StatusBadRequest, "The estate principal has no email on record" + } + + signerName := principal.DisplayName + if signerName == "" { + signerName = fallbackName + } + if signerName == "" { + signerName = signerEmail + } + return signerEmail, signerName, 0, "" +} diff --git a/api/internal/opensign/webhook.go b/api/internal/opensign/webhook.go index 1d81ef4..c1fd7c8 100644 --- a/api/internal/opensign/webhook.go +++ b/api/internal/opensign/webhook.go @@ -13,27 +13,51 @@ import ( "time" "cloud.google.com/go/firestore" + firebaseAuth "firebase.google.com/go/v4/auth" "github.com/rs/zerolog/log" "github.com/sirsi-technologies/finalwishes-api/internal/auth" ) +// userLookup is the narrow slice of the Firebase Auth client that envelope creation +// needs to resolve the estate principal's verified identity. The real +// *firebaseAuth.Client satisfies it; tests inject a fake (the concrete client cannot +// be constructed offline). This is the auth-client test seam. +type userLookup interface { + GetUser(ctx context.Context, uid string) (*firebaseAuth.UserRecord, error) +} + // WebhookHandler handles OpenSign webhook callbacks, signing status checks, and // envelope creation (via the shared-services provider). type WebhookHandler struct { fs *firestore.Client + authClient userLookup webhookSecret string provider SigningProvider + + // signerResolver lets tests inject a deterministic principal-resolution result + // without a live Firestore/Firebase Auth client. nil in production → the handler + // uses the default resolver backed by fs + authClient. + signerResolver signerResolver } // NewWebhookHandler creates a webhook handler for OpenSign signing events. It builds // the shared-services signing provider (Sirsi-first, dissociated fallback — ADR-047). -func NewWebhookHandler(fs *firestore.Client) *WebhookHandler { - return &WebhookHandler{ +// authClient resolves the estate PRINCIPAL's verified identity at envelope creation +// (the legal signer is the principal, never the caller — claude-home signer=principal +// decision 2026-06-14). It may be nil in local dev with no Firebase Auth. +func NewWebhookHandler(fs *firestore.Client, authClient *firebaseAuth.Client) *WebhookHandler { + h := &WebhookHandler{ fs: fs, - provider: NewSigningProvider(), webhookSecret: os.Getenv("OPENSIGN_WEBHOOK_SECRET"), + provider: NewSigningProvider(), + } + // Keep authClient typed-nil safe: only assign when non-nil so the interface field + // stays a true nil (a typed-nil *Client would defeat the == nil defensive check). + if authClient != nil { + h.authClient = authClient } + return h } // HandleWebhook processes OpenSign signing completion webhooks. diff --git a/api/internal/payments/handlers.go b/api/internal/payments/handlers.go index 48b7ebd..f6f0fd6 100644 --- a/api/internal/payments/handlers.go +++ b/api/internal/payments/handlers.go @@ -7,10 +7,12 @@ package payments import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strings" "time" "github.com/rs/zerolog/log" @@ -365,6 +367,16 @@ func (h *Handler) HandleCreatePortalSession(w http.ResponseWriter, r *http.Reque sess, err := portalsession.New(params) if err != nil { log.Error().Err(err).Str("customer_id", customerIDStr).Str("estate_id", req.EstateID).Msg("Stripe portal session creation failed") + // The most common cause of a portal-session failure on a live account is the + // one-time Customer Portal configuration not yet being saved in the Stripe + // Dashboard (Settings > Billing > Customer Portal). Stripe surfaces this as an + // invalid_request_error mentioning "configuration". Give the user an actionable + // message in that case instead of a bare 500. + if isStripePortalNotConfigured(err) { + writeError(w, http.StatusServiceUnavailable, + "Subscription management isn't available yet. Please contact support and we'll help you update your plan.") + return + } writeError(w, http.StatusInternalServerError, "Failed to create subscription management session") return } @@ -376,6 +388,24 @@ func (h *Handler) HandleCreatePortalSession(w http.ResponseWriter, r *http.Reque }) } +// isStripePortalNotConfigured reports whether a portal-session error is the +// "Customer Portal not configured in the Dashboard" case. Stripe returns this as an +// invalid_request_error whose message mentions a missing/default configuration +// (e.g. "No configuration provided and your test mode default configuration has not +// been created"). We match defensively on both the structured Stripe error and its +// message so a Dashboard-config gap reads as a clear "unavailable" rather than a 500. +func isStripePortalNotConfigured(err error) bool { + var serr *stripe.Error + if !errors.As(err, &serr) { + return false + } + if serr.Type != stripe.ErrorTypeInvalidRequest { + return false + } + msg := strings.ToLower(serr.Msg) + return strings.Contains(msg, "configuration") || strings.Contains(msg, "customer portal") +} + // HandleWebhook processes Stripe webhook events. func (h *Handler) HandleWebhook(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(io.LimitReader(r.Body, 65536)) diff --git a/api/internal/probate/forms.go b/api/internal/probate/forms.go index f47f58c..fbd09c4 100644 --- a/api/internal/probate/forms.go +++ b/api/internal/probate/forms.go @@ -2,12 +2,13 @@ package probate import ( "context" - "encoding/json" "net/http" + "strconv" + "strings" "time" + "unicode" "cloud.google.com/go/firestore" - "github.com/rs/zerolog/log" "github.com/sirsi-technologies/finalwishes-api/internal/auth" ) @@ -87,11 +88,19 @@ func (h *Handler) HandleGetFormTemplates(w http.ResponseWriter, r *http.Request) executorAddress, _ = exData["address"].(string) } - // Compute total asset value for small estate evaluation - var totalAssetValue string + // Compute total asset values for the inventory and small-estate templates. + // + // Rule 9: these strings are stamped into legal-form prefill fields, and the + // personal-property total gates small-estate qualification. We therefore + // derive a REAL dollar figure from the recorded asset values — never a proxy + // like a count. If any recorded value cannot be parsed into a number, the + // derived total would be misleading, so we leave the field BLANK for the + // affiant to complete by hand rather than assert an unverifiable amount. + var totalAssetValue, personalPropertyValue string assetSnaps, err := h.fs.Collection("estates").Doc(estateID).Collection("assets").Documents(ctx).GetAll() if err == nil { - totalAssetValue = computeTotalAssets(assetSnaps) + totalAssetValue = computeTotalAssets(assetSnaps, false) + personalPropertyValue = computeTotalAssets(assetSnaps, true) } county := countyOfDeath @@ -149,7 +158,7 @@ func (h *Handler) HandleGetFormTemplates(w http.ResponseWriter, r *http.Request) "dateOfDeath": dateOfDeath, "affiantName": executorName, "affiantAddress": executorAddress, - "totalPersonalProperty": totalAssetValue, + "totalPersonalProperty": personalPropertyValue, "threshold": "$150,000", "waitingPeriod": "30 days after date of death", "vehiclesExcluded": "Yes — vehicles do not count toward the $150,000 limit", @@ -186,50 +195,106 @@ func (h *Handler) HandleGetFormTemplates(w http.ResponseWriter, r *http.Request) }) } -// computeTotalAssets sums all asset values from Firestore snapshots. -func computeTotalAssets(snaps []*firestore.DocumentSnapshot) string { - // For now, return the count — proper valuation requires parsing value strings - if len(snaps) == 0 { - return "$0" - } - return jsonString(len(snaps)) + " assets recorded" -} +// realEstateTypes are asset `type` values that represent real property, which +// is excluded from the Illinois small-estate personal-property threshold +// (755 ILCS 5/25-1). Matching is case-insensitive and substring-based so that +// "Real Estate", "real property", and "Residential Real Estate" all qualify. +var realEstateTypes = []string{"real estate", "real property", "realty", "land"} -func jsonString(v int) string { - b, _ := json.Marshal(v) - return string(b) +func isRealProperty(assetType string) bool { + t := strings.ToLower(strings.TrimSpace(assetType)) + for _, re := range realEstateTypes { + if strings.Contains(t, re) { + return true + } + } + return false } -// HandleGetFormData returns pre-filled data for a specific form. -// GET /api/v1/probate/forms/:formId?estate_id=xxx -func (h *Handler) HandleGetFormData(w http.ResponseWriter, r *http.Request) { - userID, err := auth.RequireUserID(r.Context()) +// parseCurrency converts a recorded asset value string (e.g. "$485,000", +// "124500.50", "1,200") into a numeric dollar amount. It returns ok=false when +// the string contains no parseable amount, so callers can fail closed (leave a +// legal-form field blank) rather than assert a fabricated figure (Rule 9). +func parseCurrency(s string) (float64, bool) { + // Strip currency symbols, thousands separators, and any whitespace + // (including non-breaking/thin spaces some locales use as group separators). + cleaned := strings.Map(func(r rune) rune { + if r == '$' || r == ',' || unicode.IsSpace(r) { + return -1 + } + return r + }, s) + if cleaned == "" { + return 0, false + } + v, err := strconv.ParseFloat(cleaned, 64) if err != nil { - writeError(w, http.StatusUnauthorized, "Authentication required") - return + return 0, false } + return v, true +} - estateID := r.URL.Query().Get("estate_id") - formID := r.URL.Query().Get("form_id") - if estateID == "" || formID == "" { - writeError(w, http.StatusBadRequest, "estate_id and form_id query parameters are required") - return +// formatCurrency renders a dollar amount with thousands separators, e.g. +// 609500 -> "$609,500". Whole amounts drop the cents; fractional amounts keep +// two decimals. +func formatCurrency(v float64) string { + negative := v < 0 + if negative { + v = -v } + whole := int64(v) + frac := v - float64(whole) - ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) - defer cancel() + digits := strconv.FormatInt(whole, 10) + var grouped strings.Builder + for i, d := range digits { + if i > 0 && (len(digits)-i)%3 == 0 { + grouped.WriteByte(',') + } + grouped.WriteRune(d) + } - if err := h.verifyEstateAccess(ctx, userID, estateID); err != nil { - writeError(w, http.StatusForbidden, "You do not have access to this estate") - return + out := "$" + grouped.String() + if frac > 0.0049 { + out += strings.TrimPrefix(strconv.FormatFloat(frac, 'f', 2, 64), "0") + } + if negative { + out = "-" + out } + return out +} - // For now, reuse HandleGetFormTemplates logic and filter by ID - // In a full implementation, this would return richer per-form data - log.Debug().Str("estate_id", estateID).Str("form_id", formID).Msg("Form data requested") - writeJSON(w, http.StatusOK, map[string]interface{}{ - "formId": formID, - "disclaimer": formDisclaimer, - "message": "Use the /forms endpoint for pre-filled templates", - }) +// computeTotalAssets derives a real dollar valuation from recorded asset values. +// +// When personalPropertyOnly is true, real-property assets are excluded so the +// result matches the Illinois small-estate personal-property threshold +// (755 ILCS 5/25-1). When false, every asset is summed for the general estate +// inventory. +// +// Rule 9: if ANY contributing asset has a value that cannot be parsed into a +// number, the sum would be unverifiable, so we return "" — the form field is +// left blank for the affiant to complete rather than stamped with a guess. An +// estate with zero contributing assets returns "$0" (a fact, not a guess). +func computeTotalAssets(snaps []*firestore.DocumentSnapshot, personalPropertyOnly bool) string { + var total float64 + var contributing int + for _, snap := range snaps { + data := snap.Data() + assetType, _ := data["type"].(string) + if personalPropertyOnly && isRealProperty(assetType) { + continue + } + raw, _ := data["value"].(string) + amount, ok := parseCurrency(raw) + if !ok { + // Unverifiable total — refuse to fabricate a legal figure (Rule 9). + return "" + } + total += amount + contributing++ + } + if contributing == 0 { + return "$0" + } + return formatCurrency(total) } diff --git a/api/internal/service/estate/service.go b/api/internal/service/estate/service.go index 85b9662..7057ae8 100644 --- a/api/internal/service/estate/service.go +++ b/api/internal/service/estate/service.go @@ -11,22 +11,40 @@ import ( "cloud.google.com/go/firestore" "cloud.google.com/go/storage" "connectrpc.com/connect" - "google.golang.org/api/iterator" - "google.golang.org/protobuf/types/known/timestamppb" "github.com/sirsi-technologies/finalwishes-api/internal/auth" estatev1 "github.com/sirsi-technologies/finalwishes-api/internal/gen/estate/v1" "github.com/sirsi-technologies/finalwishes-api/internal/gen/estate/v1/estatev1connect" ) -// Server implements the EstateService with Google Cloud integrations +// Server implements the EstateService with Google Cloud integrations. +// +// SURFACE SCOPE (2026-06-14): EstateService intentionally implements ONLY the +// RPCs the product actually consumes. Today that is GenerateUploadUrl (the +// signed-URL issuer used by every vault/memoir/obituary/time-capsule uploader — +// see web/src/lib/client.ts). The estate read/write surface (ListEstates, +// GetEstateMetadata, ListAssets, AddAsset, ListBeneficiaries, AddBeneficiary, +// ListVaultDocuments, ListMemoirs, UploadMemoir, GetObituary, SaveObituary, +// GetAIInsight, GetGovernanceSettings, ListNotifications, RegisterEstate) is +// served directly from Firestore by the React app, and AI guidance is served by +// internal/guidance (HandleGetScore -> advisor.GenerateInsight, Claude Opus + +// RAG). Those proto RPCs therefore have ZERO consumers. +// +// Rather than ship handlers that fabricated "34% complete" / "Lockhart Estate" / +// "Sarah Johnson" demo data — which masked real Firestore failures on the live +// path and shipped invented AI text — we deliberately do NOT override them here. +// The embedded UnimplementedEstateServiceHandler answers each unimplemented RPC +// with a clean connect.CodeUnimplemented ("... is not implemented"), an honest +// signal instead of fabricated data. When/if any of these become the canonical +// path, implement the real Firestore-backed handler here (gated by +// checkEstateAccess) and remove the corresponding direct-Firestore read in web/. type Server struct { estatev1connect.UnimplementedEstateServiceHandler fs *firestore.Client sc *storage.Client } -// NewServer returns an initialized server +// NewServer returns an initialized server. func NewServer(fs *firestore.Client, sc *storage.Client) *Server { return &Server{ fs: fs, @@ -42,7 +60,9 @@ func (s *Server) checkEstateAccess(ctx context.Context, estateID string, writeRe return connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication required")) } if s.fs == nil { - return nil + // Without an estate store we cannot prove membership; deny rather than + // fall through to an unauthenticated grant. + return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("estate store not configured")) } docRef := s.fs.Collection("estate_users").Doc(userID + "_" + estateID) doc, err := docRef.Get(ctx) @@ -60,6 +80,11 @@ func (s *Server) checkEstateAccess(ctx context.Context, estateID string, writeRe } // --- Vault & Storage (Signed URLs) --- +// +// GenerateUploadUrl is the only EstateService RPC the product consumes. It +// issues a short-lived V4 signed PUT URL (and a longer-lived GET URL) for a +// vault object, with a server-enforced byte cap, after verifying the caller has +// write access to the estate. func (s *Server) GenerateUploadUrl(ctx context.Context, req *connect.Request[estatev1.GenerateUploadUrlRequest]) (*connect.Response[estatev1.GenerateUploadUrlResponse], error) { if err := s.checkEstateAccess(ctx, req.Msg.EstateId, true); err != nil { @@ -113,525 +138,3 @@ func (s *Server) GenerateUploadUrl(ctx context.Context, req *connect.Request[est FinalUrl: downloadURL, }), nil } - -// --- Core Estate Management (Firestore) --- - -func (s *Server) ListBeneficiaries(ctx context.Context, req *connect.Request[estatev1.ListBeneficiariesRequest]) (*connect.Response[estatev1.ListBeneficiariesResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - if s.fs == nil { - return connect.NewResponse(&estatev1.ListBeneficiariesResponse{ - Beneficiaries: []*estatev1.Beneficiary{ - {Id: "1", Name: "Sarah Johnson", Relation: "Spouse", Share: "50%", Status: "Verified", Email: "sarah@example.com"}, - }, - }), nil - } - - var beneficiaries []*estatev1.Beneficiary - iter := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("beneficiaries").Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to fetch beneficiaries: %w", err)) - } - - var b estatev1.Beneficiary - if err := doc.DataTo(&b); err != nil { - continue - } - b.Id = doc.Ref.ID - beneficiaries = append(beneficiaries, &b) - } - - return connect.NewResponse(&estatev1.ListBeneficiariesResponse{Beneficiaries: beneficiaries}), nil -} - -func (s *Server) AddBeneficiary(ctx context.Context, req *connect.Request[estatev1.AddBeneficiaryRequest]) (*connect.Response[estatev1.AddBeneficiaryResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, true); err != nil { - return nil, err - } - - if s.fs == nil { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("firestore not initialized")) - } - - newBene := &estatev1.Beneficiary{ - Name: req.Msg.Name, - Relation: req.Msg.Relation, - Email: req.Msg.Email, - Share: "TBD", - Status: "Invited", - } - - docRef, _, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("beneficiaries").Add(ctx, map[string]interface{}{ - "name": newBene.Name, - "relation": newBene.Relation, - "email": newBene.Email, - "share": newBene.Share, - "status": newBene.Status, - }) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to add beneficiary: %w", err)) - } - - newBene.Id = docRef.ID - return connect.NewResponse(&estatev1.AddBeneficiaryResponse{Beneficiary: newBene}), nil -} - -func (s *Server) ListEstates(ctx context.Context, req *connect.Request[estatev1.ListEstatesRequest]) (*connect.Response[estatev1.ListEstatesResponse], error) { - if s.fs == nil { - var estates []*estatev1.EstateSummary - if req.Msg.UserId == "user_tameeka" || req.Msg.UserId == "Tameeka116" { - estates = []*estatev1.EstateSummary{ - {Id: "estate_lockhart", Name: "Lockhart Estate", Role: "Owner"}, - {Id: "trust_lockhart", Name: "Lockhart Dynasty Trust", Role: "Executor"}, - } - } else { - estates = []*estatev1.EstateSummary{ - {Id: "estate-77b", Name: "The Lockhart Estate Shard", Role: "Owner"}, - {Id: "estate-99c", Name: "The Lockhart Global Trust", Role: "Executor"}, - } - } - return connect.NewResponse(&estatev1.ListEstatesResponse{Estates: estates}), nil - } - - // Derive the UID from the verified token, NOT req.Msg.UserId — trusting the - // client-supplied user_id let any authenticated user enumerate ANOTHER user's - // estate IDs (the keystone for reading their obituary/metadata/notifications by ID). - userID := auth.UserIDFromContext(ctx) - if userID == "" { - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication required")) - } - - var estates []*estatev1.EstateSummary - iter := s.fs.Collection("estates").Where("user_id", "==", userID).Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to list estates: %w", err)) - } - - var summary estatev1.EstateSummary - if err := doc.DataTo(&summary); err != nil { - continue - } - summary.Id = doc.Ref.ID - estates = append(estates, &summary) - } - - return connect.NewResponse(&estatev1.ListEstatesResponse{Estates: estates}), nil -} - -func (s *Server) RegisterEstate(ctx context.Context, req *connect.Request[estatev1.RegisterEstateRequest]) (*connect.Response[estatev1.RegisterEstateResponse], error) { - if s.fs == nil { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("firestore not initialized")) - } - - // Derive the UID from the verified token, NOT req.Msg.UserId — persisting a - // client-supplied user_id lets an authenticated caller create an estate OWNED - // BY ANOTHER USER (planting an estate into someone else's ListEstates, or - // orphaning it under a fabricated id). Same trust boundary as ListEstates. - userID := auth.UserIDFromContext(ctx) - if userID == "" { - return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("authentication required")) - } - - docRef, _, err := s.fs.Collection("estates").Add(ctx, map[string]interface{}{ - "user_id": userID, - "name": req.Msg.Name, - "type": req.Msg.Type, - "role": "Owner", // Default for creator - "status": "Active", - "completion_percentage": 10, - "authority_mode": int32(estatev1.AuthorityMode_AUTHORITY_MODE_OWNER), - "next_review_date": time.Now().AddDate(0, 1, 0), - }) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to register estate: %w", err)) - } - - return connect.NewResponse(&estatev1.RegisterEstateResponse{ - Estate: &estatev1.EstateSummary{ - Id: docRef.ID, - Name: req.Msg.Name, - Role: "Owner", - }, - }), nil -} - -func (s *Server) GetEstateMetadata(ctx context.Context, req *connect.Request[estatev1.GetEstateMetadataRequest]) (*connect.Response[estatev1.GetEstateMetadataResponse], error) { - // Ungated reads of estates/{id} leaked tier + MFA status + last-login for any - // estate (targeting recon). Gate on estate membership. - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - if s.fs == nil { - name := "Lockhart Estate" - completion := int32(100) - return connect.NewResponse(&estatev1.GetEstateMetadataResponse{ - Id: req.Msg.EstateId, - Name: name, - Status: "Active", - CompletionPercentage: completion, - AuthorityMode: estatev1.AuthorityMode_AUTHORITY_MODE_OWNER, - }), nil - } - - doc, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Get(ctx) - if err != nil { - // Provide default if not in Firestore yet for demo - return connect.NewResponse(&estatev1.GetEstateMetadataResponse{ - Status: "Active", - CompletionPercentage: 34, - AuthorityMode: estatev1.AuthorityMode_AUTHORITY_MODE_OWNER, - }), nil - } - - var meta estatev1.GetEstateMetadataResponse - if err := doc.DataTo(&meta); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to decode metadata: %w", err)) - } - - return connect.NewResponse(&meta), nil -} - -func (s *Server) ListAssets(ctx context.Context, req *connect.Request[estatev1.ListAssetsRequest]) (*connect.Response[estatev1.ListAssetsResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - if s.fs == nil { - var assets []*estatev1.Asset - if req.Msg.EstateId == "estate_lockhart" { - assets = []*estatev1.Asset{ - {Name: "Lockhart Family Residence (Chicago)", Type: "Real Estate", Value: "$1,250,000", Status: "Verified"}, - {Name: "Lockhart Heritage Investment Fund", Type: "Securities", Value: "$2,450,000", Status: "Verified"}, - {Name: "Chase Private Client - Savings", Type: "Cash", Value: "$425,000", Status: "Verified"}, - {Name: "Digital Vault - Rare Collectibles", Type: "Digital Assets", Value: "$85,000", Status: "Verified"}, - } - } else { - assets = []*estatev1.Asset{ - {Name: "Family Home", Type: "Real Estate", Value: "$485,000", Status: "Verified"}, - {Name: "Savings Account (Chase)", Type: "Cash", Value: "$124,500", Status: "Pending"}, - } - } - return connect.NewResponse(&estatev1.ListAssetsResponse{Assets: assets, TotalCount: int32(len(assets))}), nil - } - - var assets []*estatev1.Asset - iter := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("assets").Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to fetch assets: %w", err)) - } - - var a estatev1.Asset - if err := doc.DataTo(&a); err != nil { - continue - } - assets = append(assets, &a) - } - - return connect.NewResponse(&estatev1.ListAssetsResponse{Assets: assets, TotalCount: int32(len(assets))}), nil -} - -func (s *Server) AddAsset(ctx context.Context, req *connect.Request[estatev1.AddAssetRequest]) (*connect.Response[estatev1.AddAssetResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, true); err != nil { - return nil, err - } - - if s.fs == nil { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("firestore not initialized")) - } - - newAsset := &estatev1.Asset{ - Name: req.Msg.Name, - Type: req.Msg.Type, - Value: req.Msg.Value, - Status: "Verified", - } - - _, _, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("assets").Add(ctx, map[string]interface{}{ - "name": newAsset.Name, - "type": newAsset.Type, - "value": newAsset.Value, - "status": newAsset.Status, - }) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to add asset: %w", err)) - } - - return connect.NewResponse(&estatev1.AddAssetResponse{Asset: newAsset}), nil -} - -func (s *Server) ListVaultDocuments(ctx context.Context, req *connect.Request[estatev1.ListVaultDocumentsRequest]) (*connect.Response[estatev1.ListVaultDocumentsResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - if s.fs == nil { - var docs []*estatev1.VaultDocument - if req.Msg.EstateId == "estate_lockhart" { - docs = []*estatev1.VaultDocument{ - {Name: "Lockhart_Master_Trust_2026.pdf", Date: "Feb 10, 2026", Size: "4.2 MB", Category: "Legal"}, - {Name: "Estate_Tax_Clearance_IL.pdf", Date: "Mar 01, 2026", Size: "1.1 MB", Category: "Tax"}, - {Name: "Family_Heritage_Deed.pdf", Date: "Jan 15, 2026", Size: "8.5 MB", Category: "Property"}, - {Name: "Final_Wishes_Protocol_Auth.pdf", Date: "Mar 17, 2026", Size: "0.5 MB", Category: "Protocol"}, - } - } else { - docs = []*estatev1.VaultDocument{ - {Name: "Last_Will_Testament_2025.pdf", Date: "Mar 14, 2025", Size: "2.4 MB", Category: "Legal"}, - } - } - return connect.NewResponse(&estatev1.ListVaultDocumentsResponse{Documents: docs}), nil - } - - var docs []*estatev1.VaultDocument - iter := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("vault").Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to fetch vault docs: %w", err)) - } - - var d estatev1.VaultDocument - if err := doc.DataTo(&d); err != nil { - continue - } - docs = append(docs, &d) - } - - return connect.NewResponse(&estatev1.ListVaultDocumentsResponse{Documents: docs}), nil -} - -func (s *Server) ListMemoirs(ctx context.Context, req *connect.Request[estatev1.ListMemoirsRequest]) (*connect.Response[estatev1.ListMemoirsResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - if s.fs == nil { - var memoirs []*estatev1.Memoir - if req.Msg.EstateId == "estate_lockhart" { - memoirs = []*estatev1.Memoir{ - {Id: "mem-1", Title: "Mommy - Legacy Tape 01", Type: "video", Url: "/assets/tameeka/mommy.mp4", DateAdded: "Mar 17, 2026", Visibility: "private"}, - {Id: "mem-2", Title: "Musical Tribute - Live Session", Type: "video", Url: "/assets/tameeka/musical tribute.mp4", DateAdded: "Mar 17, 2026", Visibility: "all_heirs"}, - {Id: "mem-3", Title: "Mom Memorial - Heritage Photo", Type: "photo", Url: "/assets/tameeka/mom memorial.jpg", DateAdded: "Feb 10, 2026", Visibility: "private"}, - {Id: "mem-4", Title: "Mom Dance - Legacy Profile", Type: "photo", Url: "/assets/tameeka/mom dance.jpg", DateAdded: "Jan 15, 2026", Visibility: "private"}, - } - } else { - memoirs = []*estatev1.Memoir{ - {Id: "1", Title: "Legacy Tape 01", Type: "video", Url: "/memoirs/legacy.mp4", DateAdded: "Today", Visibility: "private"}, - } - } - return connect.NewResponse(&estatev1.ListMemoirsResponse{Memoirs: memoirs}), nil - } - - var memoirs []*estatev1.Memoir - iter := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("memoirs").Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to fetch memoirs: %w", err)) - } - - var m estatev1.Memoir - if err := doc.DataTo(&m); err != nil { - continue - } - m.Id = doc.Ref.ID - memoirs = append(memoirs, &m) - } - - return connect.NewResponse(&estatev1.ListMemoirsResponse{Memoirs: memoirs}), nil -} - -func (s *Server) UploadMemoir(ctx context.Context, req *connect.Request[estatev1.UploadMemoirRequest]) (*connect.Response[estatev1.UploadMemoirResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, true); err != nil { - return nil, err - } - - if s.fs == nil { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("firestore not initialized")) - } - - newMemoir := &estatev1.Memoir{ - Title: req.Msg.Title, - Type: req.Msg.Type, - Url: req.Msg.Url, - DateAdded: time.Now().Format("Jan 02, 2006"), - Visibility: "private", - } - - docRef, _, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("memoirs").Add(ctx, map[string]interface{}{ - "title": newMemoir.Title, - "type": newMemoir.Type, - "url": newMemoir.Url, - "date_added": newMemoir.DateAdded, - "visibility": newMemoir.Visibility, - }) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to save memoir: %w", err)) - } - - newMemoir.Id = docRef.ID - return connect.NewResponse(&estatev1.UploadMemoirResponse{Memoir: newMemoir}), nil -} - -func (s *Server) GetObituary(ctx context.Context, req *connect.Request[estatev1.GetObituaryRequest]) (*connect.Response[estatev1.GetObituaryResponse], error) { - // SaveObituary gated writes but this READ did not — any user could read any - // estate's obituary (the deceased's life narrative) by ID. Gate it. - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - if s.fs == nil { - content := "Marcus Aurelius was a philosopher-king." - status := "Draft" - if req.Msg.EstateId == "estate_lockhart" { - content = "Tameeka Lockhart, the guardian of the Lockhart Legacy, has established this final record as a testament to strength, family, and the enduring spirit of the Lockhart Estate. Her contributions to the community and her family remain indelible." - status = "Verified" - } - return connect.NewResponse(&estatev1.GetObituaryResponse{ - Content: content, - Status: status, - LastUpdated: timestamppb.Now(), - }), nil - } - - doc, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("governance").Doc("obituary").Get(ctx) - if err != nil { - // If document doesn't exist, return a default draft - return connect.NewResponse(&estatev1.GetObituaryResponse{ - Content: "", - Status: "None", - LastUpdated: timestamppb.Now(), - }), nil - } - - var obit estatev1.GetObituaryResponse - if err := doc.DataTo(&obit); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to decode obituary: %w", err)) - } - - // Handle timestamp conversion if needed, otherwise firestore stores it - return connect.NewResponse(&obit), nil -} - -func (s *Server) SaveObituary(ctx context.Context, req *connect.Request[estatev1.SaveObituaryRequest]) (*connect.Response[estatev1.SaveObituaryResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, true); err != nil { - return nil, err - } - - if s.fs == nil { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("firestore not initialized")) - } - - _, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("governance").Doc("obituary").Set(ctx, map[string]interface{}{ - "content": req.Msg.Content, - "status": "Draft", - "last_updated": timestamppb.Now(), - }, firestore.MergeAll) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to save obituary: %w", err)) - } - - return connect.NewResponse(&estatev1.SaveObituaryResponse{Success: true}), nil -} - -func (s *Server) GetAIInsight(ctx context.Context, req *connect.Request[estatev1.GetAIInsightRequest]) (*connect.Response[estatev1.GetAIInsightResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - insight := "Heritage Protocol Synchronized. Your estate is 100% complete and secured via the Protocol Ledger. All shards are active and verified." - if req.Msg.EstateId != "estate_lockhart" { - insight = "Protocol Optimization Required. Your estate is 34% complete. Assigning remaining beneficiary shares will activate the full Guardian Protocol." - } - return connect.NewResponse(&estatev1.GetAIInsightResponse{ - Insight: insight, - ActionLabel: "View Ledger", - ActionUrl: "/dashboard/notifications", - }), nil -} - -func (s *Server) GetGovernanceSettings(ctx context.Context, req *connect.Request[estatev1.GetGovernanceSettingsRequest]) (*connect.Response[estatev1.GetGovernanceSettingsResponse], error) { - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - - if s.fs == nil { - return connect.NewResponse(&estatev1.GetGovernanceSettingsResponse{ - Settings: &estatev1.GovernanceSettings{ - MfaEnabled: true, - BiometricRelease: false, - EmailAlerts: true, - RecoveryKeyStatus: "Verified", - StatusReportsFrequency: "Weekly", - }, - }), nil - } - - doc, err := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("governance").Doc("settings").Get(ctx) - if err != nil { - return connect.NewResponse(&estatev1.GetGovernanceSettingsResponse{ - Settings: &estatev1.GovernanceSettings{MfaEnabled: true}, - }), nil - } - - var settings estatev1.GovernanceSettings - if err := doc.DataTo(&settings); err != nil { - return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to decode settings: %w", err)) - } - - return connect.NewResponse(&estatev1.GetGovernanceSettingsResponse{Settings: &settings}), nil -} - -func (s *Server) ListNotifications(ctx context.Context, req *connect.Request[estatev1.ListNotificationsRequest]) (*connect.Response[estatev1.ListNotificationsResponse], error) { - // Ungated reads leaked another estate's settlement/Guardian/security notifications. - if err := s.checkEstateAccess(ctx, req.Msg.EstateId, false); err != nil { - return nil, err - } - if s.fs == nil { - notifications := []*estatev1.Notification{ - {Title: "Security Active", Time: "10 mins ago", Type: "success", Desc: "Protocols verified."}, - } - return connect.NewResponse(&estatev1.ListNotificationsResponse{Notifications: notifications}), nil - } - - var notifications []*estatev1.Notification - iter := s.fs.Collection("estates").Doc(req.Msg.EstateId).Collection("notifications").OrderBy("time", firestore.Desc).Limit(10).Documents(ctx) - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - break // Graceful degradation - } - - var n estatev1.Notification - if err := doc.DataTo(&n); err != nil { - continue - } - notifications = append(notifications, &n) - } - - return connect.NewResponse(&estatev1.ListNotificationsResponse{Notifications: notifications}), nil -} diff --git a/api/internal/service/estate/service_test.go b/api/internal/service/estate/service_test.go index 52e4212..769b213 100644 --- a/api/internal/service/estate/service_test.go +++ b/api/internal/service/estate/service_test.go @@ -10,450 +10,150 @@ import ( estatev1 "github.com/sirsi-technologies/finalwishes-api/internal/gen/estate/v1" ) -// --- Helper --- +// These tests pin the HONEST EstateService contract (2026-06-14): the service +// implements ONLY GenerateUploadUrl. Every other proto RPC is intentionally not +// overridden, so the embedded UnimplementedEstateServiceHandler answers it with +// connect.CodeUnimplemented instead of fabricated "Lockhart Estate" / "Sarah +// Johnson" / "34% complete" demo data. The previous suite asserted that +// fabricated data; it has been replaced to assert the real, non-mock behavior. -// newTestServer returns a Server with nil Firestore/Storage clients (demo mode). +// --- Helpers --- + +// newTestServer returns a Server with nil Firestore/Storage clients. func newTestServer() *Server { return NewServer(nil, nil) } -// testCtx returns a context with a test user ID injected, so checkEstateAccess passes. +// testCtx returns a context with a test user ID injected. func testCtx() context.Context { return auth.InjectUserIDForTest(context.Background(), "test-user") } // ========================================================= -// ListEstates -// ========================================================= - -func TestListEstates_DemoMode_KnownUser(t *testing.T) { - s := newTestServer() - resp, err := s.ListEstates(testCtx(), connect.NewRequest(&estatev1.ListEstatesRequest{ - UserId: "user_tameeka", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Estates) != 2 { - t.Fatalf("expected 2 estates, got %d", len(resp.Msg.Estates)) - } - if resp.Msg.Estates[0].Name != "Lockhart Estate" { - t.Errorf("expected 'Lockhart Estate', got %q", resp.Msg.Estates[0].Name) - } - if resp.Msg.Estates[1].Role != "Executor" { - t.Errorf("expected Executor role for second estate, got %q", resp.Msg.Estates[1].Role) - } -} - -func TestListEstates_DemoMode_UnknownUser(t *testing.T) { - s := newTestServer() - resp, err := s.ListEstates(testCtx(), connect.NewRequest(&estatev1.ListEstatesRequest{ - UserId: "some_other_user", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Estates) != 2 { - t.Fatalf("expected 2 estates for unknown user, got %d", len(resp.Msg.Estates)) - } - if resp.Msg.Estates[0].Id != "estate-77b" { - t.Errorf("expected estate-77b, got %q", resp.Msg.Estates[0].Id) - } -} - -// ========================================================= -// GetEstateMetadata -// ========================================================= - -func TestGetEstateMetadata_DemoMode(t *testing.T) { - s := newTestServer() - resp, err := s.GetEstateMetadata(testCtx(), connect.NewRequest(&estatev1.GetEstateMetadataRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Name != "Lockhart Estate" { - t.Errorf("expected 'Lockhart Estate', got %q", resp.Msg.Name) - } - if resp.Msg.CompletionPercentage != 100 { - t.Errorf("expected 100%% completion, got %d", resp.Msg.CompletionPercentage) - } - if resp.Msg.Status != "Active" { - t.Errorf("expected Active status, got %q", resp.Msg.Status) - } - if resp.Msg.AuthorityMode != estatev1.AuthorityMode_AUTHORITY_MODE_OWNER { - t.Errorf("expected OWNER authority mode, got %v", resp.Msg.AuthorityMode) - } -} - -func TestGetEstateMetadata_PreservesEstateID(t *testing.T) { - s := newTestServer() - resp, err := s.GetEstateMetadata(testCtx(), connect.NewRequest(&estatev1.GetEstateMetadataRequest{ - EstateId: "custom-id-123", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Id != "custom-id-123" { - t.Errorf("expected estate ID to be echoed back, got %q", resp.Msg.Id) - } -} - -// ========================================================= -// ListBeneficiaries -// ========================================================= - -func TestListBeneficiaries_DemoMode(t *testing.T) { - s := newTestServer() - resp, err := s.ListBeneficiaries(testCtx(), connect.NewRequest(&estatev1.ListBeneficiariesRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Beneficiaries) != 1 { - t.Fatalf("expected 1 beneficiary, got %d", len(resp.Msg.Beneficiaries)) - } - b := resp.Msg.Beneficiaries[0] - if b.Name != "Sarah Johnson" { - t.Errorf("expected Sarah Johnson, got %q", b.Name) - } - if b.Share != "50%" { - t.Errorf("expected 50%% share, got %q", b.Share) - } -} - -// ========================================================= -// AddBeneficiary — requires Firestore, should fail in demo mode +// checkEstateAccess — without an estate store, membership cannot be proven, +// so access is DENIED (FailedPrecondition), never silently granted. // ========================================================= -func TestAddBeneficiary_DemoMode_FailsGracefully(t *testing.T) { +func TestCheckEstateAccess_NoStore_Denied(t *testing.T) { s := newTestServer() - _, err := s.AddBeneficiary(testCtx(), connect.NewRequest(&estatev1.AddBeneficiaryRequest{ - EstateId: "estate_lockhart", - Name: "New Heir", - Relation: "Child", - Email: "newhier@test.com", - })) + err := s.checkEstateAccess(testCtx(), "estate_x", false) if err == nil { - t.Fatal("expected error when Firestore is nil, got nil") - } - // Should be an Unimplemented error since fs is nil - if connect.CodeOf(err) != connect.CodeUnimplemented { - t.Errorf("expected Unimplemented code, got %v", connect.CodeOf(err)) + t.Fatal("expected error when estate store is not configured") } -} - -// ========================================================= -// ListAssets -// ========================================================= - -func TestListAssets_DemoMode_LockhartEstate(t *testing.T) { - s := newTestServer() - resp, err := s.ListAssets(testCtx(), connect.NewRequest(&estatev1.ListAssetsRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Assets) != 4 { - t.Fatalf("expected 4 assets for lockhart, got %d", len(resp.Msg.Assets)) - } - if resp.Msg.TotalCount != 4 { - t.Errorf("expected total count 4, got %d", resp.Msg.TotalCount) - } - // Verify first asset - if resp.Msg.Assets[0].Type != "Real Estate" { - t.Errorf("expected Real Estate type, got %q", resp.Msg.Assets[0].Type) - } -} - -func TestListAssets_DemoMode_OtherEstate(t *testing.T) { - s := newTestServer() - resp, err := s.ListAssets(testCtx(), connect.NewRequest(&estatev1.ListAssetsRequest{ - EstateId: "other-estate", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Assets) != 2 { - t.Fatalf("expected 2 assets for other estate, got %d", len(resp.Msg.Assets)) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("expected FailedPrecondition, got %v", connect.CodeOf(err)) } } -// ========================================================= -// AddAsset — requires Firestore -// ========================================================= - -func TestAddAsset_DemoMode_FailsGracefully(t *testing.T) { +func TestCheckEstateAccess_Unauthenticated(t *testing.T) { s := newTestServer() - _, err := s.AddAsset(testCtx(), connect.NewRequest(&estatev1.AddAssetRequest{ - EstateId: "estate_lockhart", - Name: "New Car", - Type: "Vehicle", - Value: "$50,000", - })) + err := s.checkEstateAccess(context.Background(), "estate_x", false) if err == nil { - t.Fatal("expected error when Firestore is nil") + t.Fatal("expected error when no user is in context") } - if connect.CodeOf(err) != connect.CodeUnimplemented { - t.Errorf("expected Unimplemented code, got %v", connect.CodeOf(err)) + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("expected Unauthenticated, got %v", connect.CodeOf(err)) } } // ========================================================= -// ListVaultDocuments +// GenerateUploadUrl — the only implemented RPC. With nil clients it is gated +// by checkEstateAccess (no store -> FailedPrecondition) before any signing. // ========================================================= -func TestListVaultDocuments_DemoMode_Lockhart(t *testing.T) { - s := newTestServer() - resp, err := s.ListVaultDocuments(testCtx(), connect.NewRequest(&estatev1.ListVaultDocumentsRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Documents) != 4 { - t.Fatalf("expected 4 documents, got %d", len(resp.Msg.Documents)) - } - // Verify categories - categories := map[string]bool{} - for _, d := range resp.Msg.Documents { - categories[d.Category] = true - } - for _, expected := range []string{"Legal", "Tax", "Property", "Protocol"} { - if !categories[expected] { - t.Errorf("expected category %q in documents", expected) - } - } -} - -func TestListVaultDocuments_DemoMode_Other(t *testing.T) { +func TestGenerateUploadUrl_NoStore_FailsClosed(t *testing.T) { s := newTestServer() - resp, err := s.ListVaultDocuments(testCtx(), connect.NewRequest(&estatev1.ListVaultDocumentsRequest{ - EstateId: "other-estate", + _, err := s.GenerateUploadUrl(testCtx(), connect.NewRequest(&estatev1.GenerateUploadUrlRequest{ + EstateId: "estate_x", + FileName: "test.pdf", + ContentType: "application/pdf", })) - if err != nil { - t.Fatalf("unexpected error: %v", err) + if err == nil { + t.Fatal("expected error when estate store / storage client is nil") } - if len(resp.Msg.Documents) != 1 { - t.Fatalf("expected 1 document, got %d", len(resp.Msg.Documents)) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("expected FailedPrecondition, got %v", connect.CodeOf(err)) } } -// ========================================================= -// GenerateUploadUrl — requires Storage client -// ========================================================= - -func TestGenerateUploadUrl_DemoMode_FailsGracefully(t *testing.T) { +func TestGenerateUploadUrl_Unauthenticated(t *testing.T) { s := newTestServer() - _, err := s.GenerateUploadUrl(testCtx(), connect.NewRequest(&estatev1.GenerateUploadUrlRequest{ - EstateId: "estate_lockhart", + _, err := s.GenerateUploadUrl(context.Background(), connect.NewRequest(&estatev1.GenerateUploadUrlRequest{ + EstateId: "estate_x", FileName: "test.pdf", ContentType: "application/pdf", })) if err == nil { - t.Fatal("expected error when storage client is nil") + t.Fatal("expected error when caller is unauthenticated") } - if connect.CodeOf(err) != connect.CodeFailedPrecondition { - t.Errorf("expected FailedPrecondition code, got %v", connect.CodeOf(err)) + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("expected Unauthenticated, got %v", connect.CodeOf(err)) } } // ========================================================= -// ListMemoirs +// Unimplemented surface — every formerly-mocked RPC now returns Unimplemented +// (no fabricated data, no hardcoded Lockhart/Sarah-Johnson/percentages). // ========================================================= -func TestListMemoirs_DemoMode_Lockhart(t *testing.T) { +func TestUnimplementedRPCs_ReturnUnimplemented(t *testing.T) { s := newTestServer() - resp, err := s.ListMemoirs(testCtx(), connect.NewRequest(&estatev1.ListMemoirsRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Memoirs) != 4 { - t.Fatalf("expected 4 memoirs, got %d", len(resp.Msg.Memoirs)) - } - // Verify mix of types - types := map[string]int{} - for _, m := range resp.Msg.Memoirs { - types[m.Type]++ - } - if types["video"] != 2 { - t.Errorf("expected 2 videos, got %d", types["video"]) - } - if types["photo"] != 2 { - t.Errorf("expected 2 photos, got %d", types["photo"]) - } -} + ctx := testCtx() -func TestListMemoirs_DemoMode_Other(t *testing.T) { - s := newTestServer() - resp, err := s.ListMemoirs(testCtx(), connect.NewRequest(&estatev1.ListMemoirsRequest{ - EstateId: "other", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Memoirs) != 1 { - t.Fatalf("expected 1 memoir, got %d", len(resp.Msg.Memoirs)) + assertUnimplemented := func(t *testing.T, name string, err error) { + t.Helper() + if err == nil { + t.Fatalf("%s: expected Unimplemented error, got nil (fabricated response?)", name) + } + if connect.CodeOf(err) != connect.CodeUnimplemented { + t.Errorf("%s: expected Unimplemented, got %v", name, connect.CodeOf(err)) + } } -} -// ========================================================= -// UploadMemoir — requires Firestore -// ========================================================= + _, err := s.ListEstates(ctx, connect.NewRequest(&estatev1.ListEstatesRequest{UserId: "user_tameeka"})) + assertUnimplemented(t, "ListEstates", err) -func TestUploadMemoir_DemoMode_FailsGracefully(t *testing.T) { - s := newTestServer() - _, err := s.UploadMemoir(testCtx(), connect.NewRequest(&estatev1.UploadMemoirRequest{ - EstateId: "estate_lockhart", - Title: "Test Memoir", - Type: "video", - Url: "/test.mp4", - })) - if err == nil { - t.Fatal("expected error when Firestore is nil") - } - if connect.CodeOf(err) != connect.CodeUnimplemented { - t.Errorf("expected Unimplemented code, got %v", connect.CodeOf(err)) - } -} + _, err = s.RegisterEstate(ctx, connect.NewRequest(&estatev1.RegisterEstateRequest{Name: "X", Type: "Trust"})) + assertUnimplemented(t, "RegisterEstate", err) -// ========================================================= -// GetObituary -// ========================================================= + _, err = s.GetEstateMetadata(ctx, connect.NewRequest(&estatev1.GetEstateMetadataRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "GetEstateMetadata", err) -func TestGetObituary_DemoMode_Lockhart(t *testing.T) { - s := newTestServer() - resp, err := s.GetObituary(testCtx(), connect.NewRequest(&estatev1.GetObituaryRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Status != "Verified" { - t.Errorf("expected Verified status, got %q", resp.Msg.Status) - } - if resp.Msg.Content == "" { - t.Error("expected non-empty obituary content") - } - if resp.Msg.LastUpdated == nil { - t.Error("expected LastUpdated timestamp") - } -} + _, err = s.ListAssets(ctx, connect.NewRequest(&estatev1.ListAssetsRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "ListAssets", err) -func TestGetObituary_DemoMode_Other(t *testing.T) { - s := newTestServer() - resp, err := s.GetObituary(testCtx(), connect.NewRequest(&estatev1.GetObituaryRequest{ - EstateId: "other", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Status != "Draft" { - t.Errorf("expected Draft status, got %q", resp.Msg.Status) - } -} + _, err = s.AddAsset(ctx, connect.NewRequest(&estatev1.AddAssetRequest{EstateId: "estate_lockhart", Name: "Car"})) + assertUnimplemented(t, "AddAsset", err) -// ========================================================= -// SaveObituary — requires Firestore -// ========================================================= + _, err = s.ListBeneficiaries(ctx, connect.NewRequest(&estatev1.ListBeneficiariesRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "ListBeneficiaries", err) -func TestSaveObituary_DemoMode_FailsGracefully(t *testing.T) { - s := newTestServer() - _, err := s.SaveObituary(testCtx(), connect.NewRequest(&estatev1.SaveObituaryRequest{ - EstateId: "estate_lockhart", - Content: "Updated content", - })) - if err == nil { - t.Fatal("expected error when Firestore is nil") - } - if connect.CodeOf(err) != connect.CodeUnimplemented { - t.Errorf("expected Unimplemented code, got %v", connect.CodeOf(err)) - } -} + _, err = s.AddBeneficiary(ctx, connect.NewRequest(&estatev1.AddBeneficiaryRequest{EstateId: "estate_lockhart", Name: "Heir"})) + assertUnimplemented(t, "AddBeneficiary", err) -// ========================================================= -// GetAIInsight -// ========================================================= + _, err = s.ListVaultDocuments(ctx, connect.NewRequest(&estatev1.ListVaultDocumentsRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "ListVaultDocuments", err) -func TestGetAIInsight_LockhartEstate(t *testing.T) { - s := newTestServer() - resp, err := s.GetAIInsight(testCtx(), connect.NewRequest(&estatev1.GetAIInsightRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Insight == "" { - t.Error("expected non-empty insight") - } - if resp.Msg.ActionLabel != "View Ledger" { - t.Errorf("expected 'View Ledger', got %q", resp.Msg.ActionLabel) - } -} + _, err = s.ListMemoirs(ctx, connect.NewRequest(&estatev1.ListMemoirsRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "ListMemoirs", err) -func TestGetAIInsight_OtherEstate(t *testing.T) { - s := newTestServer() - resp, err := s.GetAIInsight(testCtx(), connect.NewRequest(&estatev1.GetAIInsightRequest{ - EstateId: "other", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if resp.Msg.Insight == "" { - t.Error("expected non-empty insight for other estate") - } -} + _, err = s.UploadMemoir(ctx, connect.NewRequest(&estatev1.UploadMemoirRequest{EstateId: "estate_lockhart", Title: "M"})) + assertUnimplemented(t, "UploadMemoir", err) -// ========================================================= -// GetGovernanceSettings -// ========================================================= + _, err = s.GetObituary(ctx, connect.NewRequest(&estatev1.GetObituaryRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "GetObituary", err) -func TestGetGovernanceSettings_DemoMode(t *testing.T) { - s := newTestServer() - resp, err := s.GetGovernanceSettings(testCtx(), connect.NewRequest(&estatev1.GetGovernanceSettingsRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - settings := resp.Msg.Settings - if settings == nil { - t.Fatal("expected non-nil settings") - } - if !settings.MfaEnabled { - t.Error("expected MFA enabled in demo mode") - } - if !settings.EmailAlerts { - t.Error("expected email alerts enabled") - } - if settings.RecoveryKeyStatus != "Verified" { - t.Errorf("expected Verified recovery key, got %q", settings.RecoveryKeyStatus) - } -} + _, err = s.SaveObituary(ctx, connect.NewRequest(&estatev1.SaveObituaryRequest{EstateId: "estate_lockhart", Content: "x"})) + assertUnimplemented(t, "SaveObituary", err) -// ========================================================= -// ListNotifications -// ========================================================= + _, err = s.GetAIInsight(ctx, connect.NewRequest(&estatev1.GetAIInsightRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "GetAIInsight", err) -func TestListNotifications_DemoMode(t *testing.T) { - s := newTestServer() - resp, err := s.ListNotifications(testCtx(), connect.NewRequest(&estatev1.ListNotificationsRequest{ - EstateId: "estate_lockhart", - })) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if len(resp.Msg.Notifications) != 1 { - t.Fatalf("expected 1 notification, got %d", len(resp.Msg.Notifications)) - } - if resp.Msg.Notifications[0].Type != "success" { - t.Errorf("expected success type, got %q", resp.Msg.Notifications[0].Type) - } + _, err = s.GetGovernanceSettings(ctx, connect.NewRequest(&estatev1.GetGovernanceSettingsRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "GetGovernanceSettings", err) + + _, err = s.ListNotifications(ctx, connect.NewRequest(&estatev1.ListNotificationsRequest{EstateId: "estate_lockhart"})) + assertUnimplemented(t, "ListNotifications", err) } // ========================================================= diff --git a/docs/ADR-INDEX.md b/docs/ADR-INDEX.md index 8907f66..c02aa9a 100644 --- a/docs/ADR-INDEX.md +++ b/docs/ADR-INDEX.md @@ -1,6 +1,6 @@ # Architecture Decision Records (ADR) Index -This document indexes all Architecture Decision Records for the Sirsi Nexus platform. +This document indexes all Architecture Decision Records for FinalWishes (The Estate Operating System). ## What is an ADR? @@ -124,4 +124,4 @@ Each ADR follows this structure: --- -*Last updated: 2026-04-14* +*Last updated: 2026-06-14* diff --git a/docs/REQUIREMENTS_SPECIFICATION.md b/docs/REQUIREMENTS_SPECIFICATION.md index c5cc780..9e36b98 100644 --- a/docs/REQUIREMENTS_SPECIFICATION.md +++ b/docs/REQUIREMENTS_SPECIFICATION.md @@ -108,16 +108,27 @@ Initial launch supports **3 priority jurisdictions**: ### 2.4 Verify Phase (FR-400) -#### FR-401: Death Certificate Processing — ⏳ Deferred to Tier 3 +#### FR-401: Death Certificate Processing — ✅ Implemented (IL) - System SHALL accept death certificate upload -- System SHALL perform OCR extraction of death certificate data -- *Not yet implemented. Post-settlement workflow.* - -#### FR-402: Executor Activation — ⏳ Deferred to Tier 3 -- System SHALL send notification to designated Executors upon death verification -- System SHALL require Executor identity re-verification -- System SHALL provide cooling-off period before asset access -- *Full activation workflow not yet implemented. Basic executor role assignment exists.* +- System SHALL extract death certificate facts (decedent name, dates) from the + document-intelligence analysis pipeline +- System SHALL require executor/admin confirmation of the extracted facts +- **Implementation:** `api/internal/probate/deathcert.go` + (`HandleSubmitDeathCertAnalysis` reads the AI analysis already produced by the + document-intelligence pipeline; `HandleConfirmDeathCert` records executor + confirmation; `HandleGetDeathCertFacts` returns stored facts). Surfaced in the + probate route (`web/src/routes/estates.$estateId.probate.tsx` → + `getDeathCertFacts` / `confirmDeathCert`). + +#### FR-402: Executor Activation — ✅ Implemented (IL) +- System SHALL allow the designated executor to confirm their role after death verification +- System SHALL notify estate members upon executor activation +- System SHALL gate activation on executor/admin estate access +- **Implementation:** `api/internal/probate/executor.go` + (`HandleGetExecutorStatus`, `HandleConfirmExecutorRole`, `notifyEstateMembers`). + Surfaced in the probate route via `getExecutorStatus` / `confirmExecutorRole`. +- *Scope note:* identity re-verification and an explicit cooling-off timer remain + future hardening; role assignment, confirmation, and member notification ship. #### FR-403: Beneficiary Notification — ⏳ Deferred to Tier 3 - System SHALL notify Heirs of their designation (after Executor activation) @@ -130,9 +141,19 @@ Initial launch supports **3 priority jurisdictions**: - System SHALL support institution templates for launch states (IL, MN, MD) - *Not yet implemented.* -#### FR-502: Government Notifications — ⏳ Deferred to Tier 3 +#### FR-502: Probate Guidance & Court Forms — ✅ Implemented (IL) - System SHALL provide state-specific probate guidance for launch states -- *Not yet implemented.* +- System SHALL surface the correct court form references for the estate's value + (full probate vs. small-estate affidavit) +- **Implementation:** `api/internal/probate/forms.go` returns Illinois / Cook + County Probate Division form references — petition for letters (**CCP0315**), + oath/bond (**CCP0312/CCP0313**), and the Illinois small-estate-affidavit path + (**755 ILCS 5/ Art. XXV**, threshold per **IL Probate Act §14-1**) — each with + its official court URL. `directives.go` serves Illinois advance directives; + `illinois.go` holds the state ruleset. Surfaced in the probate route alongside + `SettlementGantt` and `QuorumPanel`. +- *Scope note:* generated institution/government notification *letters* (FR-501) + remain deferred; this requirement covers guidance + form references, which ship. #### FR-503: Account Freeze Requests — ⏳ Deferred to Tier 3 - *Not yet implemented.* @@ -243,10 +264,16 @@ Initial launch supports **3 priority jurisdictions**: - System SHALL create a sacred, emotional moment for the heir - **Implementation:** Heir Welcome screen with Owner's legacy content -#### FR-913: SMS Invitation Queue — ✅ Implemented -- System SHALL support phone number collection for heir invitations -- System SHALL queue SMS notifications for delivery -- **Implementation:** SMS invitation queue with phone number input +#### FR-913: SMS Invitation Notifications — 🔮 Future (email-only at GA) +- System SHALL notify invitees of estate invitations +- **Implementation:** Invitation delivery is **email-only** (Gmail API via the + `sendMail` Cloud Function). The phone-number/SMS path was **removed** rather + than shipped: no SMS provider is wired, so a queued text would never be + delivered while the UI reported success. The phone input and `sms_queue` write + have been deleted from the invite flow. SMS delivery is deferred until a real + provider is adopted (Sirsi Sign shared SMS/MFA rail per ADR-047, or a managed + Twilio extension), at which point the delivery function and phone field will be + reintroduced together. --- @@ -397,9 +424,9 @@ Initial launch supports **3 priority jurisdictions**: - [x] User can sign documents electronically (OpenSign integration) - [x] User can share memorial pages publicly (QR code sharing) - [x] AI Shepherd provides guidance, scoring, and obituary drafting -- [ ] Executor can activate estate after death verification (Tier 3) -- [ ] System generates notification letters for institutions (Tier 3) -- [ ] System provides state-specific probate guidance (IL, MN, MD) (Tier 3) +- [x] Executor can activate estate after death verification (probate engine — IL; FR-401/402) +- [ ] System generates notification letters for institutions (Tier 3 — FR-501 deferred) +- [x] System provides state-specific probate guidance + court forms (IL shipped; MN, MD deferred) (FR-502) --- @@ -417,11 +444,11 @@ Initial launch supports **3 priority jurisdictions**: | FR-301 | Document Intelligence | ✅ Implemented | | FR-302 | Asset Discovery | ⏳ Deferred (Tier 2) | | FR-303 | Contact Import | ⏳ Deferred | -| FR-401 | Death Certificate Processing | ⏳ Deferred (Tier 3) | -| FR-402 | Executor Activation | ⏳ Deferred (Tier 3) | +| FR-401 | Death Certificate Processing | ✅ Implemented (IL) | +| FR-402 | Executor Activation | ✅ Implemented (IL) | | FR-403 | Beneficiary Notification | ⏳ Deferred (Tier 3) | | FR-501 | Institution Notification | ⏳ Deferred (Tier 3) | -| FR-502 | Government Notifications | ⏳ Deferred (Tier 3) | +| FR-502 | Probate Guidance & Court Forms | ✅ Implemented (IL) | | FR-503 | Account Freeze Requests | ⏳ Deferred (Tier 3) | | FR-601 | Asset Transfer | ⏳ Deferred (Tier 3) | | FR-602 | Final Accounting | 🔮 Future | @@ -443,7 +470,7 @@ Initial launch supports **3 priority jurisdictions**: | FR-910 | Per-Person Content Visibility | ✅ Implemented | | FR-911 | Owner Welcome Experience | ✅ Implemented | | FR-912 | Heir Welcome Sacred Moment | ✅ Implemented | -| FR-913 | SMS Invitation Queue | ✅ Implemented | +| FR-913 | SMS Invitation Notifications | 🔮 Future (email-only at GA — no provider wired) | --- @@ -454,3 +481,4 @@ Initial launch supports **3 priority jurisdictions**: | 1.0.0 | 2025-11-26 | FinalWishes Team | Initial draft | | 1.1.0 | 2025-11-26 | Claude | 6-state MVP scope, Firebase/GCP constraints, 4-month timeline | | 2.0.0 | 2026-04-17 | Claude | Complete status audit: 8 FRs implemented, 13 deferred/future, 13 new Life Companion requirements (FR-901 through FR-913) added. Integration table updated to reflect actual GCP stack. Geographic scope narrowed to 3 states (IL, MN, MD). Tech constraints updated to reflect Go + React 19 + Vite 8 stack. | +| 2.1.0 | 2026-06-14 | Claude | Status reconciliation. FR-913 re-statused ✅→🔮 (SMS path removed — email-only delivery; no provider wired). FR-401 (death-cert facts + confirm), FR-402 (executor activation + member notify), FR-502 (IL probate guidance + Cook County court forms) re-statused ⏳ Deferred→✅ Implemented (IL) to match the shipped `api/internal/probate` engine. §6.1 acceptance + Appendix A matrix updated accordingly. | diff --git a/docs/legal-corpus/build_manifest.py b/docs/legal-corpus/build_manifest.py new file mode 100644 index 0000000..8723e6d --- /dev/null +++ b/docs/legal-corpus/build_manifest.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Deterministically assemble the CR-10 legal-corpus manifest from raw official +.gov HTML captures. NO LLM is in the text path (Rule 9): each `text` field is the +verbatim statute body, sliced between explicit markers from the official page. +""" +import re, html, json, sys + +def extract(path): + h = open(path, encoding='utf-8', errors='replace').read() + h = re.sub(r'(?is)', '', h) + h = re.sub(r'(?is)', '', h) + h = re.sub(r'(?i)', '\n', h) + h = re.sub(r'(?i)

', '\n\n', h) + h = re.sub(r'(?i)', '\n', h) + h = re.sub(r'(?i)', '\n', h) + t = re.sub(r'(?s)<[^>]+>', ' ', h) + t = html.unescape(t).replace('\xa0', ' ') + t = '\n'.join(re.sub(r'[ \t]+', ' ', ln).strip() for ln in t.split('\n')) + t = re.sub(r'\n{3,}', '\n\n', t).strip() + return t + +def slice_body(path, start_pred, end_pred=None, include_end=False): + """Return text from the first line satisfying start_pred to the line + satisfying end_pred (exclusive unless include_end).""" + lines = extract(path).split('\n') + si = next(i for i, l in enumerate(lines) if start_pred(l)) + if end_pred is None: + body = lines[si:] + else: + ei = next(i for i in range(si, len(lines)) if end_pred(lines[i])) + body = lines[si:ei + 1] if include_end else lines[si:ei] + out = '\n'.join(body) + out = re.sub(r'\n{3,}', '\n\n', out).strip() + return out + +VERIFIED = "2026-06-14T00:00:00Z" + +specs = [] + +# ---- Illinois (ilga.gov documents pages are pure statute, no chrome) ---- +def il_full(path): + return extract(path) + +specs.append(dict( + file='il-5-4-3.html', + id='il-755-ilcs-5-4-3', + jurisdiction='IL', + title='Illinois Probate Act — Will Signing and Attestation', + statuteReference='755 ILCS 5/4-3', + sourceUrl='https://www.ilga.gov/documents/legislation/ilcs/documents/075500050K4-3.htm', + publisher='Illinois General Assembly', + licenseNote='U.S. state statute — public domain (Illinois Compiled Statutes)', + text=il_full('il-5-4-3.html'), +)) +specs.append(dict( + file='il-45-3-3.html', + id='il-755-ilcs-45-3-3', + jurisdiction='IL', + title='Illinois Power of Attorney Act — Statutory Short Form Power of Attorney for Property', + statuteReference='755 ILCS 45/3-3', + sourceUrl='https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K3-3.htm', + publisher='Illinois General Assembly', + licenseNote='U.S. state statute — public domain (Illinois Compiled Statutes)', + text=il_full('il-45-3-3.html'), +)) +specs.append(dict( + file='il-45-4-10.html', + id='il-755-ilcs-45-4-10', + jurisdiction='IL', + title='Illinois Power of Attorney Act — Statutory Short Form Power of Attorney for Health Care', + statuteReference='755 ILCS 45/4-10', + sourceUrl='https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K4-10.htm', + publisher='Illinois General Assembly', + licenseNote='U.S. state statute — public domain (Illinois Compiled Statutes)', + text=il_full('il-45-4-10.html'), +)) + +# ---- Maryland (mgaleg page wraps statute in site chrome) ---- +md_body = slice_body( + 'md-5-603.html', + start_pred=lambda l: l.strip().startswith('§5'), + end_pred=lambda l: l.strip() == 'Previous Next', +) +specs.append(dict( + id='md-hg-5-603', + jurisdiction='MD', + title='Maryland Health-General — Advance Directive Statutory Form', + statuteReference='Md. Code, Health-General § 5-603', + sourceUrl='https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=ghg§ion=5-603&enactments=false', + publisher='Maryland General Assembly (Department of Legislative Services)', + licenseNote='U.S. state statute — public domain (Annotated Code of Maryland)', + text=md_body, +)) + +# ---- Minnesota (revisor.mn.gov; body = heading .. History: inclusive) ---- +mn_507 = slice_body( + 'mn-507-071.html', + start_pred=lambda l: l.strip().startswith('507.071 TRANSFER ON DEATH DEEDS'), + end_pred=lambda l: l.strip().startswith('Official Publication of the State of Minnesota'), +) +specs.append(dict( + id='mn-507-071', + jurisdiction='MN', + title='Minnesota Statutes — Transfer on Death Deeds', + statuteReference='Minn. Stat. § 507.071', + sourceUrl='https://www.revisor.mn.gov/statutes/cite/507.071', + publisher='Minnesota Office of the Revisor of Statutes', + licenseNote='U.S. state statute — public domain (Minnesota Statutes)', + text=mn_507, +)) +mn_145c16 = slice_body( + 'mn-145c-16.html', + start_pred=lambda l: l.strip().startswith('145C.16 SUGGESTED FORM'), + end_pred=lambda l: l.strip().startswith('Official Publication of the State of Minnesota'), +) +specs.append(dict( + id='mn-145c-16', + jurisdiction='MN', + title='Minnesota Statutes — Health Care Directive Suggested Form', + statuteReference='Minn. Stat. § 145C.16', + sourceUrl='https://www.revisor.mn.gov/statutes/cite/145C.16', + publisher='Minnesota Office of the Revisor of Statutes', + licenseNote='U.S. state statute — public domain (Minnesota Statutes)', + text=mn_145c16, +)) +mn_145c05 = slice_body( + 'mn-145c-05.html', + start_pred=lambda l: l.strip().startswith('145C.05 SUGGESTED FORM'), + end_pred=lambda l: l.strip().startswith('Official Publication of the State of Minnesota'), +) +specs.append(dict( + id='mn-145c-05', + jurisdiction='MN', + title='Minnesota Statutes — Health Care Directive; Provisions That May Be Included', + statuteReference='Minn. Stat. § 145C.05', + sourceUrl='https://www.revisor.mn.gov/statutes/cite/145C.05', + publisher='Minnesota Office of the Revisor of Statutes', + licenseNote='U.S. state statute — public domain (Minnesota Statutes)', + text=mn_145c05, +)) + +sources = [] +for s in specs: + s.pop('file', None) + txt = s['text'] + assert txt and len(txt) > 200, f"{s['id']} text too short: {len(txt)}" + s['verifiedAt'] = VERIFIED + # order keys per schema + sources.append({ + 'id': s['id'], 'jurisdiction': s['jurisdiction'], 'title': s['title'], + 'statuteReference': s['statuteReference'], 'sourceUrl': s['sourceUrl'], + 'publisher': s['publisher'], 'licenseNote': s['licenseNote'], + 'verifiedAt': s['verifiedAt'], 'text': txt, + }) + +manifest = {'sources': sources} +out = json.dumps(manifest, indent=2, ensure_ascii=False) +open(sys.argv[1], 'w', encoding='utf-8').write(out + '\n') +for s in sources: + print(f"{s['id']:24s} {s['jurisdiction']:3s} {len(s['text']):6d} chars {s['statuteReference']}") +print(f"\nWROTE {sys.argv[1]} ({len(out)} bytes, {len(sources)} sources)") diff --git a/docs/legal-corpus/extract.py b/docs/legal-corpus/extract.py new file mode 100644 index 0000000..e76e6e7 --- /dev/null +++ b/docs/legal-corpus/extract.py @@ -0,0 +1,17 @@ +import re,html,sys +def extract(path): + h=open(path,encoding='utf-8',errors='replace').read() + h=re.sub(r'(?is)','',h) + h=re.sub(r'(?is)','',h) + h=re.sub(r'(?i)','\n',h) + h=re.sub(r'(?i)

','\n\n',h) + h=re.sub(r'(?i)','\n',h) + h=re.sub(r'(?i)','\n',h) + t=re.sub(r'(?s)<[^>]+>',' ',h) + t=html.unescape(t) + t='\n'.join(re.sub(r'[ \t]+',' ',line).strip() for line in t.split('\n')) + t=re.sub(r'\n{3,}','\n\n',t).strip() + return t +if __name__=='__main__': + t=extract(sys.argv[1]) + print("LEN",len(t)); print("====="); print(t[:int(sys.argv[2]) if len(sys.argv)>2 else 1200]) diff --git a/docs/legal-corpus/launch-states.json b/docs/legal-corpus/launch-states.json new file mode 100644 index 0000000..a368e7d --- /dev/null +++ b/docs/legal-corpus/launch-states.json @@ -0,0 +1,81 @@ +{ + "sources": [ + { + "id": "il-755-ilcs-5-4-3", + "jurisdiction": "IL", + "title": "Illinois Probate Act — Will Signing and Attestation", + "statuteReference": "755 ILCS 5/4-3", + "sourceUrl": "https://www.ilga.gov/documents/legislation/ilcs/documents/075500050K4-3.htm", + "publisher": "Illinois General Assembly", + "licenseNote": "U.S. state statute — public domain (Illinois Compiled Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "755 ILCS 5/4-3\n\n(755 ILCS 5/4-3) (from Ch. 110 1/2, par. 4-3)\n\nSec. 4-3.\nSigning and attestation.\n\n(a) Every will shall be in writing, signed by the testator or by some\nperson in his presence and by his direction and attested in the presence of\nthe testator by 2 or more credible witnesses.\n\n(b) A will that qualifies as an international will under the Uniform\nInternational Wills Act is considered to meet all the requirements of\nsubsection (a).\n\n(Source: P.A. 86-1291.)" + }, + { + "id": "il-755-ilcs-45-3-3", + "jurisdiction": "IL", + "title": "Illinois Power of Attorney Act — Statutory Short Form Power of Attorney for Property", + "statuteReference": "755 ILCS 45/3-3", + "sourceUrl": "https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K3-3.htm", + "publisher": "Illinois General Assembly", + "licenseNote": "U.S. state statute — public domain (Illinois Compiled Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "755 ILCS 45/3-3\n\n(755 ILCS 45/3-3) (from Ch. 110 1/2, par. 803-3)\n\nSec. 3-3. Statutory short form power of attorney for property.\n(a) The\nform prescribed in this Section may be known as \"statutory property power\" and may be used\nto grant an agent powers with respect to property and financial matters.\nThe \"statutory property power\" consists of the following: (1) Notice to the Individual Signing the Illinois Statutory Short Form Power of Attorney for Property; (2) Illinois Statutory Short Form Power of Attorney for Property; and (3) Notice to Agent. When a power of attorney in substantially the form prescribed in this Section is used,\nincluding all 3 items above, with item (1), the Notice to Individual Signing the Illinois Statutory Short Form Power of Attorney for Property, on a separate sheet (coversheet) in 14-point type and\nthe notarized form of acknowledgment at the end, it shall have the meaning\nand effect prescribed in this Act.\n(b) A power of attorney shall also be deemed to be in substantially the same format as the statutory form if the explanatory language throughout the form (the language following the designation \"NOTE:\") is distinguished in some way from the legal paragraphs in the form, such as the use of boldface or other difference in typeface and font or point size, even if the \"Notice\" paragraphs at the beginning are not on a separate sheet of paper or are not in 14-point type, or if the principal's initials do not appear in the acknowledgement at the end of the \"Notice\" paragraphs.\nThe validity of a power of attorney as\nmeeting the requirements of a statutory property power shall not be\naffected by the fact that one or more of the categories of optional powers\nlisted in the form are struck out or the form includes specific\nlimitations on or additions to the agent's powers, as permitted by the\nform. Nothing in this Article shall invalidate or bar use by the\nprincipal of any other or different form of power of attorney for property.\nNonstatutory property powers (i) must be executed by the principal, (ii) must\ndesignate the agent and the agent's powers, (iii) must be signed by at least one witness to the principal's signature, and (iv) must indicate that the principal has acknowledged his or her signature before a notary public. However, nonstatutory property powers need not\nconform in any other respect to the statutory property power.\n\n(c) The Notice to the Individual Signing the Illinois Statutory Short Form Power of Attorney for Property shall be substantially as follows:\n\"NOTICE TO THE INDIVIDUAL SIGNING THE ILLINOIS STATUTORY SHORT FORM POWER OF ATTORNEY FOR PROPERTY.\nPLEASE READ THIS NOTICE CAREFULLY. The form that you will be signing is a legal document. It is governed by the Illinois Power of Attorney Act. If there is anything about this form that you do not understand, you should ask a lawyer to explain it to you.\nThe purpose of this Power of Attorney is to give your designated \"agent\" broad powers to handle your financial affairs, which may include the power to pledge, sell, or dispose of any of your real or personal property, even without your consent or any advance notice to you. When using the Statutory Short Form, you may name successor agents, but you may not name co-agents.\nThis form does not impose a duty upon your agent to handle your financial affairs, so it is important that you select an agent who will agree to do this for you. It is also important to select an agent whom you trust, since you are giving that agent control over your financial assets and property. Any agent who does act for you has a duty to act in good faith for your benefit and to use due care, competence, and diligence. He or she must also act in accordance with the law and with the directions in this form. Your agent must keep a record of all receipts, disbursements, and significant actions taken as your agent.\nUnless you specifically limit the period of time that this Power of Attorney will be in effect, your agent may exercise the powers given to him or her throughout your lifetime, both before and after you become incapacitated. A court, however, can take away the powers of your agent if it finds that the agent is not acting properly. You may also revoke this Power of Attorney if you wish.\nThis Power\nof Attorney does not authorize your agent to appear in court for you as an attorney-at-law or otherwise to engage in the practice of law unless he or she is a licensed attorney who is authorized to practice law in Illinois.\nThe powers you give your agent are explained more fully in Section 3-4 of the Illinois Power of Attorney Act. This form is a part of that law. The \"NOTE\" paragraphs throughout this form are instructions.\nYou are not required to sign this Power of Attorney, but it will not take effect without your signature. You should not sign this Power of Attorney if you do not understand everything in it, and what your agent will be able to do if you do sign it.\n\nPlease place your initials on the following line indicating that you have read this Notice: .....................\nPrincipal's initials\"\n\n(d) The Illinois Statutory Short Form Power of Attorney for Property shall be substantially as follows:\n\"ILLINOIS STATUTORY SHORT FORM POWER OF ATTORNEY FOR PROPERTY\n\n1. I, ..............., (insert name and address of principal)\nhereby revoke all prior powers of attorney for property executed by me and appoint:\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(insert name and address of agent)\n\n(NOTE: You may not name co-agents using this form.)\nas my attorney-in-fact (my \"agent\") to act for me and in my name (in any\nway I could act in person) with respect to the following powers, as defined\nin Section 3-4 of the \"Statutory Short Form Power of Attorney for Property Law\"\n(including all amendments), but subject to any limitations on or additions\nto the specified powers inserted in paragraph 2 or 3 below:\n\n(NOTE: You must strike out any one or more of the following categories of\npowers you do not want your agent to have. Failure to strike the title\nof any category will cause the powers described in that category to be\ngranted to the agent. To strike out a category you must draw a line\nthrough the title of that category.)\n\n(a) Real estate transactions.\n\n(b) Financial institution transactions.\n\n(c) Stock and bond transactions.\n\n(d) Tangible personal property transactions.\n\n(e) Safe deposit box transactions.\n\n(f) Insurance and annuity transactions.\n\n(g) Retirement plan transactions.\n\n(h) Social Security, employment and military service benefits.\n\n(i) Tax matters.\n\n(j) Claims and litigation.\n\n(k) Commodity and option transactions.\n\n(l) Business operations.\n\n(m) Borrowing transactions.\n\n(n) Estate transactions.\n\n(o) All other property transactions.\n\n(NOTE: Limitations on and additions to the agent's powers may be included in this power of attorney if they are specifically described below.)\n\n2. The powers granted above shall not include the following powers or\nshall be modified or limited in the following particulars:\n(NOTE: Here you may\ninclude any specific limitations you deem appropriate, such as a\nprohibition or conditions on the sale of particular stock or real estate or\nspecial rules on borrowing by the agent.)\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n3. In addition to the powers granted above, I grant my agent the\nfollowing powers:\n(NOTE: Here you may add any other delegable powers including,\nwithout limitation, power to make gifts, exercise powers of appointment,\nname or change beneficiaries or joint tenants or revoke or amend any trust\nspecifically referred to below.)\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n(NOTE: Your agent will have authority to employ other persons as necessary to enable the agent to properly exercise the powers granted in this form, but your agent will have to make all discretionary decisions. If you want to give your agent the right to delegate discretionary decision-making powers to others, you should keep paragraph 4, otherwise it should be struck out.)\n\n4. My agent shall have the right by written instrument to delegate any\nor all of the foregoing powers involving discretionary decision-making to\nany person or persons whom my agent may select, but such delegation may be\namended or revoked by any agent (including any successor) named by me who\nis acting under this power of attorney at the time of reference.\n\n(NOTE: Your agent will be entitled to reimbursement for all reasonable expenses incurred in acting under this power of attorney. Strike out paragraph 5 if you do not want your agent to also be entitled to reasonable compensation for services as agent.)\n\n5. My agent shall be entitled to reasonable compensation for services\nrendered as agent under this power of attorney.\n\n(NOTE: This power of attorney may be amended or revoked by you at any time and in any manner. Absent amendment or revocation, the authority granted in this power of attorney will become effective at the time this power is signed and will continue until your death, unless a limitation on the beginning date or duration is made by initialing and completing one or both of paragraphs 6 and 7:)\n\n6. ( ) This power of attorney shall become effective on\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n(NOTE: Insert a future date or event during your lifetime, such as a court\ndetermination of your disability or a written determination by your physician that you are incapacitated, when you want this power to first take effect.)\n\n7. ( ) This power of attorney shall terminate on\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n(NOTE: Insert a future date or event, such as a court determination that you are not under a legal disability or a written determination by your physician that you are not incapacitated, if you want this power to terminate prior to your death.)\n\n(NOTE: If you wish to name one or more successor agents, insert the name and address of each successor agent in paragraph 8.)\n\n8. If any agent named by me shall die, become incompetent, resign\nor refuse to accept the office of agent, I name the following\n(each to act alone and successively,\nin the order named) as successor(s) to such agent:\n\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\nFor purposes of this paragraph 8, a person shall be considered to be\nincompetent if and while the person is a minor or an adjudicated\nincompetent or a person with a disability or the person is unable to give prompt and\nintelligent consideration to business matters, as certified by a licensed physician.\n\n(NOTE: If you wish to, you may name your agent as guardian of your estate if a court decides that one should be appointed. To do this, retain paragraph 9, and the court will appoint your agent if the court finds that this appointment will serve your best interests and welfare. Strike out paragraph 9 if you do not want your agent to act as guardian.)\n\n9. If a guardian of my estate (my property) is to be appointed, I\nnominate the agent acting under this power of attorney as such guardian,\nto serve without bond or security.\n\n10. I am fully informed as to all the contents of this form and\nunderstand the full import of this grant of powers to my agent.\n\n(NOTE: This form does not authorize your agent to appear in court for you as an attorney-at-law or otherwise to engage in the practice of law unless he or she is a licensed attorney who is authorized to practice law in Illinois.)\n11. The Notice to Agent is incorporated by reference and included as part of this form.\nDated: ................\nSigned . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(principal)\n\n(NOTE: This power of attorney will not be effective unless it is signed by at least one witness and your signature is notarized, using the form below. The notary may not also sign as a witness.)\n\nThe undersigned witness certifies that ..............., known to me to be\nthe same person whose name is subscribed as principal to the foregoing power of\nattorney, appeared before me and the notary public and acknowledged signing and\ndelivering the instrument as the free and voluntary act of the principal, for\nthe\nuses and purposes therein set forth. I believe him or her to be of sound mind\nand memory. The undersigned witness also certifies that the witness is not: (a) the attending physician or mental health service provider or a relative of the physician or provider; (b) an owner, operator, or relative of an owner or operator of a health care facility in which the principal is a patient or resident; (c) a parent, sibling, descendant, or any spouse of such parent, sibling, or descendant of either the principal or any agent or successor agent under the foregoing power of attorney, whether such relationship is by blood, marriage, or adoption; or (d) an agent or successor agent under the foregoing power of attorney.\n\nDated: ................\n..............................\n\nWitness\n\n(NOTE: Illinois requires only one witness, but other jurisdictions may require more than one witness. If you wish to have a second witness, have him or her certify and sign here:)\n\n(Second witness) The undersigned witness certifies that ................, known to me to be the same person whose name is subscribed as principal to the foregoing power of attorney, appeared before me and the notary public and acknowledged signing and delivering the instrument as the free and voluntary act of the principal, for the uses and purposes therein set forth. I believe him or her to be of sound mind and memory. The undersigned witness also certifies that the witness is not: (a) the attending physician or mental health service provider or a relative of the physician or provider; (b) an owner, operator, or relative of an owner or operator of a health care facility in which the principal is a patient or resident; (c) a parent, sibling, descendant, or any spouse of such parent, sibling, or descendant of either the principal or any agent or successor agent under the foregoing power of attorney, whether such relationship is by blood, marriage, or adoption; or (d) an agent or successor agent under the foregoing power of attorney.\nDated: ....................... ..............................\nWitness\n\nState of ............)\n) SS.\nCounty of ...........)\n\nThe undersigned, a notary public in and for the above county and state,\ncertifies that ......................., known to me to be the same person\nwhose name is subscribed as principal to the foregoing power of attorney,\nappeared before me and the witness(es) ............. (and ..............) in person and acknowledged\nsigning and delivering the\ninstrument as the free and voluntary act of the principal, for the uses and\npurposes therein set forth (, and certified to the correctness of the\nsignature(s) of the agent(s)).\n\nDated: ................\n..............................\n\nNotary Public\n\nMy commission expires .................\n\n(NOTE: You may, but are not required to, request your agent and successor agents to provide specimen signatures below. If you include specimen signatures in this power of attorney, you must complete the certification opposite the signatures of the agents.)\n\nSpecimen signatures of I certify that the signatures\nagent (and successors) of my agent (and successors)\nare genuine.\n.......................... .............................\n(agent) (principal)\n.......................... .............................\n(successor agent) (principal)\n.......................... .............................\n(successor agent) (principal)\n\n(NOTE: The name, address, and phone number of the person preparing this form or who assisted the principal in completing this form should be inserted below.)\n\nName: .......................\nAddress: ....................\n..............................\n..............................\nPhone: .................... \"\n\n(e) Notice to Agent. The following form may be known as \"Notice to Agent\" and shall be supplied to an agent appointed under a power of attorney for property.\n\"NOTICE TO AGENT When you accept the authority granted under this power of attorney a special legal relationship, known as agency, is created between you and the principal. Agency imposes upon you duties that continue until you resign or the power of attorney is terminated or revoked.\nAs agent you must:\n(1) do what you know the principal reasonably expects\n\nyou to do with the principal's property;\n\n(2) act in good faith for the best interest of the\n\nprincipal, using due care, competence, and diligence;\n\n(3) keep a complete and detailed record of all\n\nreceipts, disbursements, and significant actions conducted for the principal;\n\n(4) attempt to preserve the principal's estate plan,\n\nto the extent actually known by the agent, if preserving the plan is consistent with the principal's best interest; and\n\n(5) cooperate with a person who has authority to make\n\nhealth care decisions for the principal to carry out the principal's reasonable expectations to the extent actually in the principal's best interest.\n\nAs agent you must not do any of the following:\n(1) act so as to create a conflict of interest that\n\nis inconsistent with the other principles in this Notice to Agent;\n\n(2) do any act beyond the authority granted in this\n\npower of attorney;\n\n(3) commingle the principal's funds with your funds;\n(4) borrow funds or other property from the\n\nprincipal, unless otherwise authorized;\n\n(5) continue acting on behalf of the principal if you\n\nlearn of any event that terminates this power of attorney or your authority under this power of attorney, such as the death of the principal, your legal separation from the principal, or the dissolution of your marriage to the principal.\n\nIf you have special skills or expertise, you must use those special skills and expertise when acting for the principal. You must disclose your identity as an agent whenever you act for the principal by writing or printing the name of the principal and signing your own name \"as Agent\" in the following manner:\n\"(Principal's Name) by (Your Name) as Agent\"\nThe meaning of the powers granted to you is contained in Section 3-4 of the Illinois Power of Attorney Act, which is incorporated by reference into the body of the power of attorney for property document.\nIf you violate your duties as agent or act outside the authority granted to you, you may be liable for any damages, including attorney's fees and costs, caused by your violation.\nIf there is anything about this document or your duties that you do not understand, you should seek legal advice from an attorney.\"\n\n(f) The requirement of the signature of a witness in addition to the principal and the notary, imposed by Public Act 91-790, applies only to instruments executed on or after June 9, 2000 (the effective date of that Public Act).\n(NOTE: This amendatory Act of the 96th General Assembly deletes provisions that referred to the one required witness as an \"additional witness\", and it also provides for the signature of an optional \"second witness\".)\n\n(Source: P.A. 99-143, eff. 7-27-15.)" + }, + { + "id": "il-755-ilcs-45-4-10", + "jurisdiction": "IL", + "title": "Illinois Power of Attorney Act — Statutory Short Form Power of Attorney for Health Care", + "statuteReference": "755 ILCS 45/4-10", + "sourceUrl": "https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K4-10.htm", + "publisher": "Illinois General Assembly", + "licenseNote": "U.S. state statute — public domain (Illinois Compiled Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "755 ILCS 45/4-10\n\n(755 ILCS 45/4-10) (from Ch. 110 1/2, par. 804-10)\n\nSec. 4-10. Statutory short form power of attorney for health care.\n\n(a) The form prescribed in this Section (sometimes also referred to in this Act as the\n\"statutory health care power\") may be used to grant an agent powers with\nrespect to the principal's own health care; but the statutory health care\npower is not intended to be exclusive nor to cover delegation of a parent's\npower to control the health care of a minor child, and no provision of this\nArticle shall be construed to invalidate or bar use by the principal of any\nother or\ndifferent form of power of attorney for health care. Nonstatutory health\ncare powers must be\nexecuted by the principal, designate the agent and the agent's powers, and\ncomply with the limitations in Section 4-5 of this Article, but they need not be witnessed or\nconform in any other respect to the statutory health care power.\nNo specific format is required for the statutory health care power of attorney other than the notice must precede the form. The statutory health care power may be included in or\ncombined with any\nother form of power of attorney governing property or other matters.\n\nThe signature and execution requirements set forth in this Article are satisfied by: (i) written signatures or initials; or (ii) electronic signatures or computer-generated signature codes. Electronic documents under this Act may be created, signed, or revoked electronically using a generic, technology-neutral system in which each user is assigned a unique identifier that is securely maintained and in a manner that meets the regulatory requirements for a digital or electronic signature. Compliance with the standards defined in the Uniform Electronic Transactions Act or the implementing rules of the Hospital Licensing Act for medical record entry authentication for author validation of the documentation, content accuracy, and completeness meets this standard.\n(b) The Illinois Statutory Short Form Power of Attorney for Health Care shall be substantially as follows:\nNOTICE TO THE INDIVIDUAL SIGNING THE POWER OF ATTORNEY FOR HEALTH CARE No one can predict when a serious illness or accident might occur. When it does, you may need someone else to speak or make health care decisions for you. If you plan now, you can increase the chances that the medical treatment you get will be the treatment you want.\nIn Illinois, you can choose someone to be your \"health care agent\". Your agent is the person you trust to make health care decisions for you if you are unable or do not want to make them yourself. These decisions should be based on your personal values and wishes.\nIt is important to put your choice of agent in writing. The written form is often called an \"advance directive\". You may use this form or another form, as long as it meets the legal requirements of Illinois. There are many written and online resources to guide you and your loved ones in having a conversation about these issues. You may find it helpful to look at these resources while thinking about and discussing your advance directive.\nWHAT ARE THE THINGS I WANT MY HEALTH CARE AGENT TO KNOW? The selection of your agent should be considered carefully, as your agent will have the ultimate decision-making authority once this document goes into effect, in most instances after you are no longer able to make your own decisions. While the goal is for your agent to make decisions in keeping with your preferences and in the majority of circumstances that is what happens, please know that the law does allow your agent to make decisions to direct or refuse health care interventions or withdraw treatment. Your agent will need to think about conversations you have had, your personality, and how you handled important health care issues in the past. Therefore, it is important to talk with your agent and your family about such things as:\n(i) What is most important to you in your life?\n(ii) How important is it to you to avoid pain and\n\nsuffering?\n\n(iii) If you had to choose, is it more important to\n\nyou to live as long as possible, or to avoid prolonged suffering or disability?\n\n(iv) Would you rather be at home or in a hospital for\n\nthe last days or weeks of your life?\n\n(v) Do you have religious, spiritual, or cultural\n\nbeliefs that you want your agent and others to consider?\n\n(vi) Do you wish to make a significant contribution\n\nto medical science after your death through organ or whole body donation?\n\n(vii) Do you have an existing advance directive, such\n\nas a living will, that contains your specific wishes about health care that is only delaying your death? If you have another advance directive, make sure to discuss with your agent the directive and the treatment decisions contained within that outline your preferences. Make sure that your agent agrees to honor the wishes expressed in your advance directive.\n\nWHAT KIND OF DECISIONS CAN MY AGENT MAKE? If there is ever a period of time when your physician determines that you cannot make your own health care decisions, or if you do not want to make your own decisions, some of the decisions your agent could make are to:\n(i) talk with physicians and other health care\n\nproviders about your condition.\n\n(ii) see medical records and approve who else can see\n\nthem.\n\n(iii) give permission for medical tests, medicines,\n\nsurgery, or other treatments.\n\n(iv) choose where you receive care and which\n\nphysicians and others provide it.\n\n(v) decide to accept, withdraw, or decline treatments\n\ndesigned to keep you alive if you are near death or not likely to recover. You may choose to include guidelines and/or restrictions to your agent's authority.\n\n(vi) agree or decline to donate your organs or your\n\nwhole body if you have not already made this decision yourself. This could include donation for transplant, research, and/or education. You should let your agent know whether you are registered as a donor in the First Person Consent registry maintained by the Illinois Secretary of State or whether you have agreed to donate your whole body for medical research and/or education.\n\n(vii) decide what to do with your remains after you\n\nhave died, if you have not already made plans.\n\n(viii) talk with your other loved ones to help come\n\nto a decision (but your designated agent will have the final say over your other loved ones).\n\nYour agent is not automatically responsible for your health care expenses.\nWHOM SHOULD I CHOOSE TO BE MY HEALTH CARE AGENT? You can pick a family member, but you do not have to. Your agent will have the responsibility to make medical treatment decisions, even if other people close to you might urge a different decision. The selection of your agent should be done carefully, as he or she will have ultimate decision-making authority for your treatment decisions once you are no longer able to voice your preferences. Choose a family member, friend, or other person who:\n(i) is at least 18 years old;\n(ii) knows you well;\n(iii) you trust to do what is best for you and is\n\nwilling to carry out your wishes, even if he or she may not agree with your wishes;\n\n(iv) would be comfortable talking with and\n\nquestioning your physicians and other health care providers;\n\n(v) would not be too upset to carry out your wishes\n\nif you became very sick; and\n\n(vi) can be there for you when you need it and is\n\nwilling to accept this important role.\n\nWHAT IF MY AGENT IS NOT AVAILABLE OR IS UNWILLING TO MAKE DECISIONS FOR ME? If the person who is your first choice is unable to carry out this role, then the second agent you chose will make the decisions; if your second agent is not available, then the third agent you chose will make the decisions. The second and third agents are called your successor agents and they function as back-up agents to your first choice agent and may act only one at a time and in the order you list them.\nWHAT WILL HAPPEN IF I DO NOT CHOOSE A HEALTH CARE AGENT? If you become unable to make your own health care decisions and have not named an agent in writing, your physician and other health care providers will ask a family member, friend, or guardian to make decisions for you. In Illinois, a law directs which of these individuals will be consulted. In that law, each of these individuals is called a \"surrogate\".\nThere are reasons why you may want to name an agent rather than rely on a surrogate:\n(i) The person or people listed by this law may not\n\nbe who you would want to make decisions for you.\n\n(ii) Some family members or friends might not be able\n\nor willing to make decisions as you would want them to.\n\n(iii) Family members and friends may disagree with\n\none another about the best decisions.\n\n(iv) Under some circumstances, a surrogate may not be\n\nable to make the same kinds of decisions that an agent can make.\n\nWHAT IF THERE IS NO ONE AVAILABLE WHOM I TRUST TO BE MY AGENT? In this situation, it is especially important to talk to your physician and other health care providers and create written guidance about what you want or do not want, in case you are ever critically ill and cannot express your own wishes. You can complete a living will. You can also write your wishes down and/or discuss them with your physician or other health care provider and ask him or her to write it down in your chart. You might also want to use written or online resources to guide you through this process.\nWHAT DO I DO WITH THIS FORM ONCE I COMPLETE IT? Follow these instructions after you have completed the form:\n(i) Sign the form in front of a witness. See the\n\nform for a list of who can and cannot witness it.\n\n(ii) Ask the witness to sign it, too.\n(iii) There is no need to have the form notarized.\n(iv) Give a copy to your agent and to each of your\n\nsuccessor agents.\n\n(v) Give another copy to your physician.\n(vi) Take a copy with you when you go to the hospital.\n(vii) Show it to your family and friends and others\n\nwho care for you.\n\nWHAT IF I CHANGE MY MIND? You may change your mind at any time. If you do, tell someone who is at least 18 years old that you have changed your mind, and/or destroy your document and any copies. If you wish, fill out a new form and make sure everyone you gave the old form to has a copy of the new one, including, but not limited to, your agents and your physicians. If you are concerned you may revoke your power of attorney at a time when you may need it the most, you may initial the box at the end of the form to indicate that you would like a 30-day waiting period after you voice your intent to revoke your power of attorney. This means if your agent is making decisions for you during that time, your agent can continue to make decisions on your behalf. This election is purely optional, and you do not have to choose it. If you do not choose this option, you can change your mind and revoke the power of attorney at any time.\nWHAT IF I DO NOT WANT TO USE THIS FORM? In the event you do not want to use the Illinois statutory form provided here, any document you complete must be executed by you, designate an agent who is over 18 years of age and not prohibited from serving as your agent, and state the agent's powers, but it need not be witnessed or conform in any other respect to the statutory health care power.\nIf you have questions about the use of any form, you may want to consult your physician, other health care provider, and/or an attorney.\nMY POWER OF ATTORNEY FOR HEALTH CARE\nTHIS POWER OF ATTORNEY REVOKES ALL PREVIOUS POWERS OF ATTORNEY FOR HEALTH CARE. (You must sign this form and a witness must also sign it before it is valid)\n\nMy name (Print your full name): . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nMy address: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\nI WANT THE FOLLOWING PERSON TO BE MY HEALTH CARE AGENT\n(an agent is your personal representative under state and federal law):\n(Agent name) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(Agent address) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(Agent phone number) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n(Please check box if applicable) .... If a guardian of my person is to be appointed, I nominate the agent acting under this power of attorney as guardian.\n\nSUCCESSOR HEALTH CARE AGENT(S) (optional):\nIf the agent I selected is unable or does not want to make health care decisions for me, then I request the person(s) I name below to be my successor health care agent(s). Only one person at a time can serve as my agent (add another page if you want to add more successor agent names):\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(Successor agent #1 name, address and phone number)\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n(Successor agent #2 name, address and phone number)\n\nMY AGENT CAN MAKE HEALTH CARE DECISIONS FOR ME, INCLUDING:\n(i) Deciding to accept, withdraw, or decline\n\ntreatment for any physical or mental condition of mine, including life-and-death decisions.\n\n(ii) Agreeing to admit me to or discharge me from any\n\nhospital, home, or other institution, including a mental health facility.\n\n(iii) Having complete access to my medical and mental\n\nhealth records, and sharing them with others as needed, including after I die.\n\n(iv) Carrying out the plans I have already made, or,\n\nif I have not done so, making decisions about my body or remains, including organ, tissue or whole body donation, autopsy, cremation, and burial.\n\nThe above grant of power is intended to be as broad as possible so that my agent will have the authority to make any decision I could make to obtain or terminate any type of health care, including withdrawal of nutrition and hydration and other life-sustaining measures.\n\nI AUTHORIZE MY AGENT TO (please check any one box):\n.... Make decisions for me only when I cannot make them\n\nfor myself. The physician(s) taking care of me will determine when I lack this ability.\n\n(If no box is checked, then the box above shall be\n\nimplemented.) OR\n\n.... Make decisions for me only when I cannot make them\n\nfor myself. The physician(s) taking care of me will determine when I lack this ability. Starting now, for the purpose of assisting me with my health care plans and decisions, my agent shall have complete access to my medical and mental health records, the authority to share them with others as needed, and the complete ability to communicate with my personal physician(s) and other health care providers, including the ability to require an opinion of my physician as to whether I lack the ability to make decisions for myself. OR\n\n.... Make decisions for me starting now and continuing\n\nafter I am no longer able to make them for myself. While I am still able to make my own decisions, I can still do so if I want to.\n\nThe subject of life-sustaining treatment is of particular importance. Life-sustaining treatments may include tube feedings or fluids through a tube, breathing machines, and CPR. In general, in making decisions concerning life-sustaining treatment, your agent is instructed to consider the relief of suffering, the quality as well as the possible extension of your life, and your previously expressed wishes. Your agent will weigh the burdens versus benefits of proposed treatments in making decisions on your behalf.\nAdditional statements concerning the withholding or removal of life-sustaining treatment are described below. These can serve as a guide for your agent when making decisions for you. Ask your physician or health care provider if you have any questions about these statements.\n\nSELECT ONLY ONE STATEMENT BELOW THAT BEST EXPRESSES YOUR WISHES (optional):\n.... The quality of my life is more important than the\n\nlength of my life. If I am unconscious and my attending physician believes, in accordance with reasonable medical standards, that I will not wake up or recover my ability to think, communicate with my family and friends, and experience my surroundings, I do not want treatments to prolong my life or delay my death, but I do want treatment or care to make me comfortable and to relieve me of pain.\n\n.... Staying alive is more important to me, no matter how\n\nsick I am, how much I am suffering, the cost of the procedures, or how unlikely my chances for recovery are. I want my life to be prolonged to the greatest extent possible in accordance with reasonable medical standards.\n\nSPECIFIC LIMITATIONS TO MY AGENT'S DECISION-MAKING AUTHORITY:\nThe above grant of power is intended to be as broad as possible so that your agent will have the authority to make any decision you could make to obtain or terminate any type of health care. If you wish to limit the scope of your agent's powers or prescribe special rules or limit the power to authorize autopsy or dispose of remains, you may do so specifically in this form.\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\nMy signature: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nToday's date: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nDELAYED REVOCATION .... I elect to delay revocation of this power of attorney for 30 days after I communicate my intent to revoke it.\n.... I elect for the revocation of this power of attorney to take effect immediately if I communicate my intent to revoke it.\n\nHAVE YOUR WITNESS AGREE TO WHAT IS WRITTEN BELOW, AND THEN COMPLETE THE SIGNATURE PORTION:\nI am at least 18 years old. (check one of the options below):\n.... I saw the principal sign this document, or\n.... the principal told me that the signature or mark on\n\nthe principal signature line is his or hers.\n\nI am not the agent or successor agent(s) named in this document. I am not related to the principal, the agent, or the successor agent(s) by blood, marriage, or adoption. I am not the principal's physician, advanced practice registered nurse, dentist, podiatric physician, optometrist, psychologist, or a relative of one of those individuals. I am not an owner or operator (or the relative of an owner or operator) of the health care facility where the principal is a patient or resident.\nWitness printed name: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nWitness address: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nWitness signature: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\nToday's date: . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\n\n(c) The statutory short form power of attorney for health care (the\n\"statutory health care power\") authorizes the agent to make any and all\nhealth care decisions on behalf of the principal which the principal could\nmake if present and under no disability, subject to any limitations on the\ngranted powers that appear on the face of the form, to be exercised in such\nmanner as the agent deems consistent with the intent and desires of the\nprincipal. The agent will be under no duty to exercise granted powers or\nto assume control of or responsibility for the principal's health care;\nbut when granted powers are exercised, the agent will be required to use\ndue care to act for the benefit of the principal in accordance with the\nterms of the statutory health care power and will be liable\nfor negligent exercise. The agent may act in person or through others\nreasonably employed by the agent for that purpose\nbut may not delegate authority to make health care decisions. The agent\nmay sign and deliver all instruments, negotiate and enter into all\nagreements, and do all other acts reasonably necessary to implement the\nexercise of the powers granted to the agent. Without limiting the\ngenerality of the foregoing, the statutory health care power shall include\nthe following powers, subject to any limitations appearing on the face of the form:\n\n(1) The agent is authorized to give consent to and\n\nauthorize or refuse, or to withhold or withdraw consent to, any and all types of medical care, treatment, or procedures relating to the physical or mental health of the principal, including any medication program, surgical procedures, life-sustaining treatment, or provision of food and fluids for the principal.\n\n(2) The agent is authorized to admit the principal to\n\nor discharge the principal from any and all types of hospitals, institutions, homes, residential or nursing facilities, treatment centers, and other health care institutions providing personal care or treatment for any type of physical or mental condition. The agent shall have the same right to visit the principal in the hospital or other institution as is granted to a spouse or adult child of the principal, any rule of the institution to the contrary notwithstanding.\n\n(3) The agent is authorized to contract for any and\n\nall types of health care services and facilities in the name of and on behalf of the principal and to bind the principal to pay for all such services and facilities, and to have and exercise those powers over the principal's property as are authorized under the statutory property power, to the extent the agent deems necessary to pay health care costs; and the agent shall not be personally liable for any services or care contracted for on behalf of the principal.\n\n(4) At the principal's expense and subject to\n\nreasonable rules of the health care provider to prevent disruption of the principal's health care, the agent shall have the same right the principal has to examine and copy and consent to disclosure of all the principal's medical records that the agent deems relevant to the exercise of the agent's powers, whether the records relate to mental health or any other medical condition and whether they are in the possession of or maintained by any physician, psychiatrist, psychologist, therapist, hospital, nursing home, or other health care provider. The authority under this paragraph (4) applies to any information governed by the Health Insurance Portability and Accountability Act of 1996 (\"HIPAA\") and regulations thereunder. The agent serves as the principal's personal representative, as that term is defined under HIPAA and regulations thereunder.\n\n(5) The agent is authorized: to direct that an\n\nautopsy be made pursuant to Section 2 of the Autopsy Act; to make a disposition of any part or all of the principal's body pursuant to the Illinois Anatomical Gift Act, as now or hereafter amended; and to direct the disposition of the principal's remains.\n\n(6) At any time during which there is no executor or\n\nadministrator appointed for the principal's estate, the agent is authorized to continue to pursue an application or appeal for government benefits if those benefits were applied for during the life of the principal.\n\n(d) A physician may determine that the principal is unable to make health care decisions for himself or herself only if the principal lacks decisional capacity, as that term is defined in Section 10 of the Health Care Surrogate Act.\n(e) If the principal names the agent as a guardian on the statutory short form, and if a court decides that the appointment of a guardian will serve the principal's best interests and welfare, the court shall appoint the agent to serve without bond or security.\n(f) If the agent presents the statutory short form electronically, an attending physician, emergency medical services personnel as defined by Section 3.5 of the Emergency Medical Services (EMS) Systems Act, or health care provider shall not refuse to give effect to a health care agency if the agent presents an electronic device displaying an electronic copy of an executed form as proof of the health care agency. Any person or entity that provides a statutory short form to the public shall post for a period of 2 years information on its website regarding the changes made by this amendatory Act of the 102nd General Assembly.\n(Source: P.A. 101-81, eff. 7-12-19; 101-163, eff. 1-1-20; 102-38, eff. 6-25-21; 102-181, eff. 7-30-21; 102-794, eff. 1-1-23; 102-813, eff. 5-13-22 .)" + }, + { + "id": "md-hg-5-603", + "jurisdiction": "MD", + "title": "Maryland Health-General — Advance Directive Statutory Form", + "statuteReference": "Md. Code, Health-General § 5-603", + "sourceUrl": "https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=ghg§ion=5-603&enactments=false", + "publisher": "Maryland General Assembly (Department of Legislative Services)", + "licenseNote": "U.S. state statute — public domain (Annotated Code of Maryland)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "§5–603.\n\nMaryland Advance Directive:\n\nPlanning for Future Health Care Decisions\n\nBy: Date of Birth: _________________________\n(Print Name) (Month/Day/Year)\n\nUsing this advance directive form to do health care planning is completely optional. Other forms are also valid in Maryland. No matter what form you use, talk to your family and others close to you about your wishes.\n\nThis form has two parts to state your wishes, and a third part for needed signatures. Part I of this form lets you answer this question: If you cannot (or do not want to) make your own health care decisions, who do you want to make them for you? The person you pick is called your health care agent. Make sure you talk to your health care agent (and any back–up agents) about this important role. Part II lets you write your preferences about efforts to extend your life in three situations: terminal condition, persistent vegetative state, and end–stage condition. In addition to your health care planning decisions, you can choose to become an organ donor after your death by filling out the form for that too.\n\nYou can fill out Parts I and II of this form, or only Part I, or only Part II. Use the form to reflect your wishes, then sign in front of two witnesses (Part III). If your wishes change, make a new advance directive.\n\nMake sure you give a copy of the completed form to your health care agent, your doctor, and others who might need it. Keep a copy at home in a place where someone can get it if needed. Review what you have written periodically.\n\nPART I: SELECTION OF HEALTH CARE AGENT\n\nA. Selection of Primary Agent\n\nI select the following individual as my agent to make health care decisions for me:\n\nName:\nAddress:\n\nTelephone Numbers:\n(home and cell)\n\nB. Selection of Back–up Agents\n(Optional; form valid if left blank)\n\n1. If my primary agent cannot be contacted in time or for any reason is unavailable or unable or unwilling to act as my agent, then I select the following person to act in this capacity:\n\nName: _________________________________________________________________________\nAddress: _______________________________________________________________________\n________________________________________________________________________________\nTelephone Numbers:_____________________________________________________________\n(home and cell)\n\n2. If my primary agent and my first back–up agent cannot be contacted in time or for any reason are unavailable or unable or unwilling to act as my agent, then I select the following person to act in this capacity:\n\nName: _________________________________________________________________________\nAddress: _______________________________________________________________________\n________________________________________________________________________________\nTelephone Numbers:_____________________________________________________________\n(home and cell)\n\nC. Powers and Rights of Health Care Agent\n\nI want my agent to have full power to make health care decisions for me, including the power to:\n\n1. Consent or not consent to medical procedures and treatments which my doctors offer, including things that are intended to keep me alive, like ventilators and feeding tubes;\n\n2. Decide who my doctor and other health care providers should be; and\n\n3. Decide where I should be treated, including whether I should be in a hospital, nursing home, other medical care facility, or hospice program.\n\nI also want my agent to:\n\n1. Ride with me in an ambulance if ever I need to be rushed to the hospital; and\n\n2. Be able to visit me if I am in a hospital or any other health care facility.\n\nThis advance directive does not make my agent responsible for any of the costs of my care.\n\nThis power is subject to the following conditions or limitations:\n(Optional; form valid if left blank)\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n\nD. How My Agent Is to Decide Specific Issues\n\nI trust my agent’s judgment. My agent should look first to see if there is anything in Part II of this advance directive that helps decide the issue. Then, my agent should think about the conversations we have had, my religious or other beliefs and values, my personality, and how I handled medical and other important issues in the past. If what I would decide is still unclear, then my agent is to make decisions for me that my agent believes are in my best interest. In doing so, my agent should consider the benefits, burdens, and risks of the choices presented by my doctors.\n\nE. People My Agent Should Consult\n(Optional; form valid if left blank)\n\nIn making important decisions on my behalf, I encourage my agent to consult with the following people. By filling this in, I do not intend to limit the number of people with whom my agent might want to consult or my agent’s power to make these decisions.\n\nName(s) Telephone Number(s)\n_____________________________________ ________________________________________\n_____________________________________ ________________________________________\n_____________________________________ ________________________________________\n_____________________________________ ________________________________________\n_____________________________________ ________________________________________\n_____________________________________ ________________________________________\n\nF. In Case of Pregnancy\n(Optional, for women of child–bearing years only; form valid if\nleft blank)\n\nIf I am pregnant, my agent shall follow these specific instructions:\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n\nG. Access to My Health Information – Federal Privacy Law (HIPAA)\nAuthorization\n\n1. If, prior to the time the person selected as my agent has power to act under this document, my doctor wants to discuss with that person my capacity to make my own health care decisions, I authorize my doctor to disclose protected health information which relates to that issue.\n\n2. Once my agent has full power to act under this document, my agent may request, receive, and review any information, oral or written, regarding my physical or mental health, including, but not limited to, medical and hospital records and other protected health information, and consent to disclosure of this information.\n\n3. For all purposes related to this document, my agent is my personal representative under the Health Insurance Portability and Accountability Act (HIPAA). My agent may sign, as my personal representative, any release forms or other HIPAA–related materials.\n\nH. Effectiveness of This Part\n(Read both of these statements carefully. Then, initial one only.)\n\nMy agent’s power is in effect:\n\n1. Immediately after I sign this document, subject to my right to make any decision about my health care if I want and am able to.\n\n_____\n\n((or))\n\n2. Whenever I am not able to make informed decisions about my health care, either because the doctor in charge of my care (attending physician) decides that I have lost this ability temporarily, or my attending physician and a consulting doctor agree that I have lost this ability permanently.\n\n_____\n\nIf the only thing you want to do is select a health care agent, skip Part II. Go to Part III to sign and have the advance directive witnessed. If you also want to write your treatment preferences, use Part II. Also consider becoming an organ donor, using the separate form for that.\n\nPART II: TREATMENT PREFERENCES (“LIVING WILL”)\n\nA. Statement of Goals and Values\n(Optional; form valid if left blank)\n\nI want to say something about my goals and values, and especially what’s most important to me during the last part of my life:\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n\nB. Preference in Case of Terminal Condition\n(If you want to state your preference, initial one only. If you do not want to state a preference here, cross through the whole section.)\n\nIf my doctors certify that my death from a terminal condition is imminent, even if life–sustaining procedures are used:\n\n1. Keep me comfortable and allow natural death to occur. I do not want any medical interventions used to try to extend my life. I do not want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n2. Keep me comfortable and allow natural death to occur. I do not want medical interventions used to try to extend my life. If I am unable to take enough nourishment by mouth, however, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n3. Try to extend my life for as long as possible, using all available interventions that in reasonable medical judgment would prevent or delay my death. If I am unable to take enough nourishment by mouth, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\nC. Preference in Case of Persistent Vegetative State\n(If you want to state your preference, initial one only. If you do not want to state a preference here, cross through the whole section.)\n\nIf my doctors certify that I am in a persistent vegetative state, that is, if I am not conscious and am not aware of myself or my environment or able to interact with others, and there is no reasonable expectation that I will ever regain consciousness:\n\n1. Keep me comfortable and allow natural death to occur. I do not want any medical interventions used to try to extend my life. I do not want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n2. Keep me comfortable and allow natural death to occur. I do not want medical interventions used to try to extend my life. If I am unable to take enough nourishment by mouth, however, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n3. Try to extend my life for as long as possible, using all available interventions that in reasonable medical judgment would prevent or delay my death. If I am unable to take enough nourishment by mouth, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\nD. Preference in Case of End–Stage Condition\n(If you want to state your preference, initial one only. If you do not want to state a preference here, cross through the whole section.)\n\nIf my doctors certify that I am in an end–stage condition, that is, an incurable condition that will continue in its course until death and that has already resulted in loss of capacity and complete physical dependency:\n\n1. Keep me comfortable and allow natural death to occur. I do not want any medical interventions used to try to extend my life. I do not want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n2. Keep me comfortable and allow natural death to occur. I do not want medical interventions used to try to extend my life. If I am unable to take enough nourishment by mouth, however, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\n((or))\n\n3. Try to extend my life for as long as possible, using all available interventions that in reasonable medical judgment would prevent or delay my death. If I am unable to take enough nourishment by mouth, I want to receive nutrition and fluids by tube or other medical means.\n\n_____\n\nE. Pain Relief\n\nNo matter what my condition, give me the medicine or other treatment I need to relieve pain.\n\n_____\n\nF. In Case of Pregnancy\n(Optional, for women of child–bearing years only; form valid if left blank)\n\nIf I am pregnant, my decision concerning life–sustaining procedures shall be modified as follows:\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n________________________________________________________________________________\n\nG. Effect of Stated Preferences\n(Read both of these statements carefully. Then, initial one only.)\n\n1. I realize I cannot foresee everything that might happen after I can no longer decide for myself. My stated preferences are meant to guide whoever is making decisions on my behalf and my health care providers, but I authorize them to be flexible in applying these statements if they feel that doing so would be in my best interest.\n\n_____\n\n((or))\n\n2. I realize I cannot foresee everything that might happen after I can no longer decide for myself. Still, I want whoever is making decisions on my behalf and my health care providers to follow my stated preferences exactly as written, even if they think that some alternative is better.\n\n_____\n\nPART III: SIGNATURE AND WITNESSES\n\nBy signing below as the Declarant, I indicate that I am emotionally and mentally competent to make this advance directive and that I understand its purpose and effect. I also understand that this document replaces any similar advance directive I may have completed before this date.\n\n____________________________________________ _________________________________\n(Signature of Declarant) (Date)\n\nThe Declarant signed or acknowledged signing this document in my presence and, based upon personal observation, appears to be emotionally and mentally competent to make this advance directive.\n\n____________________________________________ _________________________________\n(Signature of Witness) (Date)\n____________________________________________\nTelephone Number(s)\n_____________________________________________ ________________________________\n(Signature of Witness) (Date)\n_____________________________________________\nTelephone Number(s)\n\n(Note: Anyone selected as a health care agent in Part I may not be a witness. Also, at least one of the witnesses must be someone who will not knowingly inherit anything from the Declarant or otherwise knowingly gain a financial benefit from the Declarant’s death. Maryland law does not require this document to be notarized.)\n\nAFTER MY DEATH\n\n(This form is optional. Fill out only what reflects your wishes.)\n\nBy:________________________________________ Date of Birth:_______________________\n(Print Name) (Month/Day/Year)\n\nPART I: ORGAN DONATION\n\n(Initial the ones that you want.)\n\nUpon my death I wish to donate:\n\nAny needed organs, tissues, or eyes. _____\n\nOnly the following organs, tissues, or eyes: _____\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\nI authorize the use of my organs, tissues, or eyes:\n\nFor transplantation _____\n\nFor therapy _____\n\nFor research _____\n\nFor medical education _____\n\nFor any purpose authorized by law _____\n\nI understand that no vital organ, tissue, or eye may be removed for transplantation until after I have been pronounced dead under legal standards. This document is not intended to change anything about my health care while I am still alive. After death, I authorize any appropriate support measures to maintain the viability for transplantation of my organs, tissues, and eyes until organ, tissue, and eye recovery has been completed. I understand that my estate will not be charged for any costs related to this donation.\n\nPART II: DONATION OF BODY\n\nAfter any organ donation indicated in Part I, I wish my body to be donated for use in a medical study program.\n\n_____\n\nPART III: DISPOSITION OF BODY AND FUNERAL ARRANGEMENTS\n\nI want the following person to make decisions about the disposition of my body and my funeral arrangements:\n\n(Either initial the first or fill in the second.)\n\nThe health care agent who I named in my advance directive. _____\n\n((or))\n\nThis person:\n\nName: _________________________________________________________________________\n\nAddress: _______________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\nTelephone Numbers:_____________________________________________________________\n\n(home and cell)\n\nIf I have written my wishes below, they should be followed. If not, the person I have named should decide based on conversations we have had, my religious or other beliefs and values, my personality, and how I reacted to other peoples’ funeral arrangements. My wishes about the disposition of my body and my funeral arrangements are:\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\n________________________________________________________________________________\n\nPART IV: SIGNATURE AND WITNESSES\n\nBy signing below, I indicate that I am emotionally and mentally competent to make this donation and that I understand the purpose and effect of this document.\n\n_________________________________ ________________________________\n(Signature of Donor) (Date)\n\nThe Donor signed or acknowledged signing this donation document in my presence and, based upon personal observation, appears to be emotionally and mentally competent to make this donation.\n\n_________________________________ ________________________________\n(Signature of Witness) (Date)\n\n_________________________________\nTelephone Number(s)\n\n_________________________________ ________________________________\n(Signature of Witness) (Date)\n\n_________________________________\nTelephone Number(s)" + }, + { + "id": "mn-507-071", + "jurisdiction": "MN", + "title": "Minnesota Statutes — Transfer on Death Deeds", + "statuteReference": "Minn. Stat. § 507.071", + "sourceUrl": "https://www.revisor.mn.gov/statutes/cite/507.071", + "publisher": "Minnesota Office of the Revisor of Statutes", + "licenseNote": "U.S. state statute — public domain (Minnesota Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "507.071 TRANSFER ON DEATH DEEDS.\n\n§\nSubdivision 1. Definitions.\nFor the purposes of this section the following terms have the meanings given:\n\n(a) \"Beneficiary\" or \"grantee beneficiary\" means a person or entity named as a grantee beneficiary in a transfer on death deed, including a successor grantee beneficiary.\n\n(b) \"County agency\" means the county department or office designated to recover medical assistance benefits from the estates of decedents.\n\n(c) \"Grantor owner\" means an owner, whether individually, as a joint tenant, or as a tenant in common, named as a grantor in a transfer on death deed upon whose death the conveyance or transfer of the described real property is conditioned. Grantor owner does not include a spouse who joins in a transfer on death deed solely for the purpose of conveying or releasing statutory or other marital interests in the real property to be conveyed or transferred by the transfer on death deed.\n\n(d) \"Owner\" means a person having an ownership or other interest in all or part of the real property to be conveyed or transferred by a transfer on death deed either at the time the deed is executed or at the time the transfer becomes effective. Owner does not include a spouse who joins in a transfer on death deed solely for the purpose of conveying or releasing statutory or other marital interests in the real property to be conveyed or transferred by the transfer on death deed.\n\n(e) \"Property\" and \"interest in real property\" mean any interest in real property located in this state which is transferable on the death of the owner and includes, without limitation, an interest in real property defined in chapter 500, a mortgage, a deed of trust, a security interest in, or a security pledge of, an interest in real property, including the rights to payments of the indebtedness secured by the security instrument, a judgment, a tax lien, both the seller's and purchaser's interest in a contract for deed, land contract, purchase agreement, or earnest money contract for the sale and purchase of real property, including the rights to payments under such contracts, or any other lien on, or interest in, real property.\n\n(f) \"Recorded\" means recorded in the office of the county recorder or registrar of titles, as appropriate for the real property described in the instrument to be recorded.\n\n(g) \"State agency\" means the Department of Human Services or any successor agency or Direct Care and Treatment or any successor agency.\n\n(h) \"Transfer on death deed\" means a deed authorized under this section.\n\n§\nSubd. 2. Effect of transfer on death deed.\nA deed that conveys or assigns an interest in real property, to a grantee beneficiary and that expressly states that the deed is only effective on the death of one or more of the grantor owners, transfers the interest to the grantee beneficiary upon the death of the grantor owner upon whose death the conveyance or transfer is stated to be effective, but subject to the survivorship provisions and requirements of section 524.2-702 . Until a transfer on death deed becomes effective, it has no effect on title to the real property described in the deed, but it does create an insurable interest in the real property in favor of the designated grantee beneficiary or beneficiaries for purposes of insuring the real property against loss or damage that occurs on or after the transfer on death deed becomes effective. A transfer on death deed must comply with all provisions of Minnesota law applicable to deeds of real property including, but not limited to, the provisions of sections 507.02 , 507.24 , 507.34 , 508.48 , and 508A.48 . If a spouse who is neither a grantor owner nor an owner joins in the execution of, or consents in writing to, the transfer on death deed, such joinder or consent shall be conclusive proof that upon the transfer becoming effective, the spouse no longer has or can claim any statutory interest or other marital interest in the interest in real property transferred by the transfer on death deed. However, such transfer shall remain an interest as identified in section 256B.15 for purposes of complying with and satisfying any claim or lien as authorized by subdivision 3.\n\n§\nSubd. 3. Rights of creditors and rights of state and county under sections 246.53 , 256B.15 , 256D.16 , 261.04 , and 514.981 .\nThe interest transferred to a beneficiary under a transfer on death deed after the death of a grantor owner is transferred subject to all effective conveyances, assignments, contracts, mortgages, deeds of trust, liens, security pledges, judgments, tax liens, and any other matters or encumbrances to which the interest was subject on the date of death of the grantor owner, upon whose death the transfer becomes effective including, but not limited to, any claim by a surviving spouse who did not join in the execution of, or consent in writing to, the transfer on death deed, and any claim or lien by the state or county agency authorized by sections 246.53 , 256B.15 , 256D.16 , 261.04 , and 514.981 , if other assets of the deceased grantor's estate are insufficient to pay the amount of any such claim. A beneficiary to whom the interest is transferred after the death of a grantor owner shall be liable to account to the state or county agency with a claim or lien authorized by section 246.53 , 256B.15 , 256D.16 , 261.04 , or 514.981 , to the extent necessary to discharge any such claim remaining unpaid after application of the assets of the deceased grantor owner's estate, but such liability shall be limited to the value of the interest transferred to the beneficiary. To establish compliance with this subdivision and subdivision 23, the beneficiary must record a clearance certificate issued in accordance with subdivision 23 in each county in which the real property described in the transfer on death deed is located.\n\n§\nSubd. 4. Multiple grantee beneficiaries.\nA transfer on death deed may designate multiple grantee beneficiaries to take title as joint tenants, as tenants in common or in any other form of ownership or tenancy that is valid under the laws of this state. If a grantee joint tenant dies before the grantor owner upon whose death the transfer occurs and no successor beneficiary for the deceased grantee is designated in the transfer on death deed, the surviving joint tenants are the successors and no interest lapses.\n\n§\nSubd. 5. Successor grantee beneficiaries.\nA transfer on death deed may designate one or more successor grantee beneficiaries or a class of successor grantee beneficiaries, or both. If the transfer on death deed designates successor grantee beneficiaries or a class of successor grantee beneficiaries, the deed shall state the condition under which the interest of the successor grantee beneficiaries would vest.\n\n§\nSubd. 6. Multiple joint tenant grantors.\nIf an interest in real property is owned as joint tenants, a transfer on death deed executed by all of the owners and, if required by section 507.02 , their respective spouses, if any, that conveys an interest in real property to one or more grantee beneficiaries transfers the interest to the grantee beneficiary or beneficiaries effective only after the death of the last surviving grantor owner. If the last surviving joint tenant owner did not execute the transfer on death deed, the deed is ineffective to transfer any interest and the deed is void. An estate in joint tenancy is not severed or affected by the subsequent execution of a transfer on death deed and the right of a surviving joint tenant owner who did not execute the transfer on death deed shall prevail over a grantee beneficiary named in a transfer on death deed unless the deed specifically states that it severs the joint tenancy ownership.\n\n§\nSubd. 7. Execution by attorney-in-fact.\nA transfer on death deed may be executed by a duly appointed attorney-in-fact pursuant to a power of attorney which grants the attorney-in-fact the authority to execute deeds.\n\n§\nSubd. 8. Recording requirements and authorization.\nA transfer on death deed is valid if the deed is recorded in a county in which at least a part of the real property described in the deed is located and is recorded before the death of the grantor owner upon whose death the conveyance or transfer is effective. Notwithstanding the definition of recorded under subdivision 1, if the real property is registered property, a transfer on death deed that was recorded incorrectly or incompletely is valid if the deed was recorded before the death of the grantor owner in the office of the county recorder or the registrar of titles in a county in which at least part of the real property is located, and is memorialized on the certificate of title after death. A transfer on death deed is not effective for purposes of section 507.34 , 508.47 , or 508A.47 until the deed is properly recorded in the county in which the real property is located. When a transfer on death deed is presented for recording, no certification by the county auditor as to transfer of ownership and current and delinquent taxes shall be required or made and the transfer on death deed shall not be required to be accompanied by a certificate of real estate value. A transfer on death deed that otherwise satisfies all statutory requirements for recording may be recorded and shall be accepted for recording in the county in which the property described in the deed is located. If any part of the property described in the transfer on death deed is registered property, the registrar of titles shall accept the transfer on death deed for recording only if at least one of the grantors who executes the transfer on death deed appears of record to have an ownership interest or other interest in the real property described in the deed. No certification or approval of a transfer on death deed shall be required of the examiner of titles prior to recording of the deed in the office of the registrar of titles.\n\n§\nSubd. 9. Deed to trustee or other entity.\nA transfer on death deed may transfer an interest in real property to the trustee of an inter vivos trust even if the trust is revocable, to the trustee of a testamentary trust or to any other entity legally qualified to hold title to real property under the laws of this state.\n\n§\nSubd. 10. Revocation or modification of transfer on death deed.\n(a) A transfer on death deed may be revoked at any time by the grantor owner or, if there is more than one grantor owner, by any of the grantor owners. A revocation revokes the transfer on death deed in its entirety. To be effective, the revocation must be recorded in a county in which at least a part of the real property is located before the death of the grantor owner or owners who execute the revocation. Notwithstanding the definition of recorded under subdivision 1, if the real property is registered property, a revocation that was recorded incorrectly or incompletely is effective if it was recorded before the death of the grantor owner in the office of the county recorder or the registrar of titles in a county in which at least part of the real property is located, and is memorialized on the certificate of title after death. The revocation is not effective for purposes of section 507.34 , 508.47 , or 508A.47 until the revocation is properly recorded in a county in which the real property is located.\n\n(b) If a grantor owner conveys to a third party, subsequent to the recording of the transfer on death deed, by means other than a transfer on death deed, all or a part of such grantor owner's interest in the property described in the transfer on death deed, no transfer of the conveyed interest shall occur on such grantor owner's death and the transfer on death deed shall be ineffective as to the conveyed or transferred interests, but the transfer on death deed remains effective with respect to the conveyance or transfer on death of any other interests described in the transfer on death deed owned by the grantor owner at the time of the grantor owner's death.\n\n(c) A transfer on death deed is a \"governing instrument\" within the meaning of section 524.2-804 and, except as may otherwise be specifically provided for in the transfer on death deed, is subject to the same provisions as to revocation, revival, and nonrevocation set forth in section 524.2-804 .\n\n§\nSubd. 11. Antilapse; deceased beneficiary; words of survivorship.\n(a) Except when a successor grantee beneficiary is designated in the transfer on death deed for the grantee beneficiary who did not survive the grantor owner, if a grantee beneficiary who is a grandparent or lineal descendant of a grandparent of the grantor owner fails to survive the grantor owner, the issue of the deceased grantee beneficiary who survive the grantor owner take in place of the deceased grantee beneficiary. If they are all of the same degree of kinship to the deceased grantee beneficiary, they take equally. If they are of unequal degree, those of more remote degree take by right of representation.\n\n(b) For the purposes of this subdivision, words of survivorship such as, in a conveyance to an individual, \"if he or she survives me,\" or, in a class gift, to \"my surviving children,\" are a sufficient indication of intent to condition the conveyance or transfer upon the beneficiary surviving the grantor owner.\n\n(c) When issue of a deceased grantee beneficiary or members of a class take in place of the named grantee beneficiary pursuant to subdivision 5 or paragraph (a) or (b) or when a beneficiary dies and has no issue under paragraph (a), an affidavit of survivorship stating the names and shares of the beneficiaries or stating that a deceased beneficiary had no issue is not conclusive and a court order made in accordance with Minnesota probate law determining the beneficiaries and shares must also be recorded.\n\n§\nSubd. 12. Lapse.\nIf all beneficiaries and all successor beneficiaries, if any, designated in a transfer on death deed, and also all successor beneficiaries who would take under the antilapse provisions of subdivision 11, fail to survive the grantor owner or the last survivor of the grantor owners if there are multiple grantor owners, if the beneficiary is a trust which has been revoked prior to the grantor owner's death, or if the beneficiary is an entity no longer in existence at the grantor owner's death, no transfer shall occur and the transfer on death deed is void.\n\n§\nSubd. 13. Multiple transfer on death deeds.\nIf a grantor owner executes and records more than one transfer on death deed conveying the same interest in real property or a greater interest in the real property, or conveying part of the property in the earlier transfer on death deed, the transfer on death deed that has the latest acknowledgment date and that is recorded before the death of the grantor owner upon whose death the conveyance or transfer is conditioned is the effective transfer on death deed and all other transfer on death deeds, if any, executed by the grantor owner or the grantor owners are ineffective to transfer any interest and are void, except that if the later transfer on death deed included only part of the land of the earlier deed, the earlier deed is effective for the lands not included in the subsequent deed, absent language to the contrary in the subsequent deed.\n\n§\nSubd. 14. Nonademption; unpaid proceeds of sale, condemnation, or insurance; sale by conservator or guardian.\nIf at the time of the death of the grantor owner upon whose death the conveyance or transfer is stated to be effective, the grantor owner did not own a part or all of the real property described in the transfer on death deed, no conveyance or transfer to the beneficiary of the nonowned part of the real property shall occur upon the death of the grantor owner and the transfer on death deed is void as to the nonowned part of the real property, but the beneficiary shall have the same rights to unpaid proceeds of sale, condemnation or insurance, and, if sold by a conservator or guardian of the grantor owner during the grantor owner's lifetime, the same rights to a general pecuniary devise, as that of a specific devisee as set forth in section 524.2-606 .\n\n§\nSubd. 15. Nonexoneration.\nExcept as otherwise provided in subdivision 3, a conveyance or transfer under a transfer on death deed passes the described property subject to any mortgage or security interest existing at the date of death of the grantor owner, without right of exoneration, regardless of any statutory obligations to pay the grantor owner's debts upon death and regardless of a general directive in the grantor owner's will to pay debts.\n\n§\nSubd. 16. Disclaimer by beneficiary.\nA grantee beneficiary's interest under a transfer on death deed may be disclaimed as provided in sections 524.2-1101 to 524.2-1116 , or as otherwise provided by law.\n\n§\nSubd. 17. Effect on other conveyances.\nThis section does not prohibit other methods of conveying property that are permitted by law and that have the effect of postponing ownership or enjoyment of an interest in real property until the death of the owner. This section does not invalidate any deed that is not a transfer on death deed and that is otherwise effective to convey title to the interests and estates described in the deed that is not recorded until after the death of the owner.\n\n§\nSubd. 18. Notice, consent, and delivery not required.\nThe signature, consent or agreement of, or notice to, a grantee beneficiary under a transfer on death deed, or delivery of the transfer on death deed to the grantee beneficiary, is not required for any purpose during the lifetime of the grantor owner.\n\n§\nSubd. 19. Nonrevocation by will.\nA transfer on death deed that is executed, acknowledged, and recorded in accordance with this section is not revoked by the provisions of a will.\n\n§\nSubd. 20. Proof of survivorship and clearance from public assistance claims and liens; recording.\nAn affidavit of identity and survivorship with a certified copy of a record of death as an attachment may be combined with a clearance certificate under this section and the combined documents may be recorded separately or as one document in each county in which the real estate described in the clearance certificate is located. The affidavit must include the name and mailing address of the person to whom future property tax statements should be sent. The affidavit, record of death, and clearance certificate, whether combined or separate, shall be prima facie evidence of the facts stated in each, and the registrar of titles may rely on the statements to transfer title to the property described in the clearance certificate, except in cases where a court order is required pursuant to the provisions of subdivision 11, paragraph (c).\n\n§\nSubd. 21. After-acquired property.\nExcept as provided in this subdivision, a transfer on death deed is not effective to transfer any interest in real property acquired by a grantor owner subsequent to the date of signing of a transfer on death deed. A grantor owner may provide by specific language in a transfer on death deed that the transfer on death deed will apply to any interest in the described property acquired by the grantor owner after the signing or recording of the deed.\n\n§\nSubd. 22. Anticipatory alienation prohibited.\nThe interest of a grantee beneficiary under a transfer on death deed which has not yet become effective is not subject to alienation; assignment; encumbrance; appointment or anticipation by the beneficiary; garnishment; attachment; execution or bankruptcy proceedings; claims for alimony, support, or maintenance; payment of other obligations by any person against the beneficiary; or any other transfer, voluntary or involuntary, by or from any beneficiary.\n\n§\nSubd. 23. Clearance for public assistance claims and liens.\nAny person claiming an interest in real property conveyed or transferred by a transfer on death deed, or the person's attorney or other agent, may apply to the county agency in the county in which the real property is located for a clearance certificate for the real property described in the transfer on death deed. The application for a clearance certificate and the clearance certificate must contain the legal description of each parcel of property covered by the clearance certificate. The county agency shall provide a sufficient number of clearance certificates to allow a clearance certificate to be recorded in each county in which the real property described in the transfer on death deed is located. The real property described in the clearance certificate is bound by any conditions or other requirements imposed by the county agency as specified in the clearance certificate. If the real property is registered property, a new certificate of title must not be issued until the clearance certificate is recorded. If the clearance certificate shows the continuation of a medical assistance claim or lien after issuance of the clearance certificate, the real property remains subject to the claim or lien. If the real property is registered property, the clearance certificate must be carried forward as a memorial in any new certificate of title. The application shall contain the same information and shall be submitted, processed, and resolved in the same manner and on the same terms and conditions as provided in section 525.313 for a clearance certificate in a decree of descent proceeding, except that a copy of a notice of hearing does not have to accompany the application. The application may contain a statement that the applicant, after reasonably diligent inquiry, is not aware of the existence of a predeceased spouse or the existence of a claim which could be recovered under section 246.53 , 256B.15 , 256D.16 , 261.04 , or 514.981 . If the county agency determines that a claim or lien exists under section 246.53 , 256B.15 , 256D.16 , 261.04 , or 514.981 , the provisions of section 525.313 shall apply to collection, compromise, and settlement of the claim or lien. A person claiming an interest in real property transferred or conveyed by a transfer on death deed may petition or move the district court, as appropriate, in the county in which the real property is located or in the county in which a probate proceeding affecting the estate of the grantor of the transfer on death deed is pending, for an order allowing sale of the real property free and clear of any public assistance claim or lien but subject to disposition of the sale proceeds as provided in section 525.313 . On a showing of good cause and subject to such notice as the court may require, the court without hearing may issue an order allowing the sale free and clear of any public assistance claim or lien on such terms and conditions as the court deems advisable to protect the interests of the state or county agency.\n\n§\nSubd. 24. Form of transfer on death deed.\nA transfer on death deed may be substantially in the following form:\n\nTransfer on Death Deed\n\nI (we) ................................... (grantor owner or owners and spouses, if any, with marital status designated), grantor(s), hereby convey(s) and quitclaim(s) to .................................. (grantee beneficiary, whether one or more) effective (check only one of the following)\n\n.... on the death of the grantor owner, if only one grantor is named above, or on the death of the last of the grantor owners to die, if more than one grantor owner is named above, or\n\n.... on the death of (name of grantor owner)\n\n........................................... (must be one of the grantor owners named above), the following described real property:\n\n(Legal description)\n\nIf checked, the following optional statement applies:\n\n....When effective, this instrument conveys any and all interests in the described real property acquired by the grantor owner(s) before, on, or after the date of this instrument.\n\n.\n\n(Signature of grantor(s))\n\n(acknowledgment)\n\n§\nSubd. 25. Form of instrument of revocation.\nAn instrument of revocation may be substantially in the following form:\n\nRevocation of Transfer on Death Deed\n\nThe undersigned hereby revokes the transfer on death deed recorded on .........., ...., as Document No. .......... (or in Book .......... of ........., Page .....) in the office of the (County Recorder) (Registrar of Titles) of ............ County, Minnesota, affecting real property legally described as follows:\n\n(legal description)\n\nDated:\n\n.\n\nSignature\n\n(acknowledgment)\n\n§\nSubd. 26. Jurisdiction.\nIn counties where the district court has a probate division, the application of subdivision 11 or other issues of interpretation or validity of the transfer on death deed, and actions to enforce a medical assistance lien or claim against real property described in a transfer on death deed and any matter raised in connection with enforcement shall be determined in the probate division. In other counties, the district court shall have jurisdiction to determine any matter affecting real property purporting to be transferred by a transfer on death deed. Notwithstanding any other law to the contrary, the provisions of section 256B.15 shall apply to any proceeding to enforce a medical assistance lien or claim under chapter 524 or 525.\n\nHistory:\n2008 c 341 art 2 s 5 ; 2009 c 30 art 1 s 1 ,2; 2010 c 382 s 77 ; 2014 c 266 s 4 -9; 2024 c 91 s 1 ; 2025 c 38 art 3 s 74" + }, + { + "id": "mn-145c-16", + "jurisdiction": "MN", + "title": "Minnesota Statutes — Health Care Directive Suggested Form", + "statuteReference": "Minn. Stat. § 145C.16", + "sourceUrl": "https://www.revisor.mn.gov/statutes/cite/145C.16", + "publisher": "Minnesota Office of the Revisor of Statutes", + "licenseNote": "U.S. state statute — public domain (Minnesota Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "145C.16 SUGGESTED FORM.\nThe following is a suggested form of a health care directive and is not a required form.\n\nHEALTH CARE DIRECTIVE\n\nI, ..........................., understand this document allows me to do ONE OR BOTH of the following:\n\nPART I: Name another person (called the health care agent) to make health care decisions for me if I am unable to decide or speak for myself. My health care agent must make health care decisions for me based on the instructions I provide in this document (Part II), if any, the wishes I have made known to him or her, or must act in my best interest if I have not made my health care wishes known.\n\nAND/OR\n\nPART II: Give health care instructions to guide others making health care decisions for me. If I have named a health care agent, these instructions are to be used by the agent. These instructions may also be used by my health care providers, others assisting with my health care and my family, in the event I cannot make decisions for myself.\n\nPART I: APPOINTMENT OF HEALTH CARE AGENT\n\nTHIS IS WHO I WANT TO MAKE HEALTH CARE DECISIONS\n\nFOR ME IF I AM UNABLE TO DECIDE OR SPEAK FOR MYSELF\n\n(I know I can change my agent or alternate agent at any time and I know I do not have to appoint an agent or an alternate agent)\n\nNOTE: If you appoint an agent, you should discuss this health care directive with your agent and give your agent a copy. If you do not wish to appoint an agent, you may leave Part I blank and go to Part II.\n\nWhen I am unable to decide or speak for myself, I trust and appoint .......................... to make health care decisions for me. This person is called my health care agent.\n\nRelationship of my health care agent to me:\n.\n\nTelephone number of my health care agent:\n.\n\nAddress of my health care agent:\n.\n\n(OPTIONAL) APPOINTMENT OF ALTERNATE HEALTH CARE AGENT: If my health care agent is not reasonably available, I trust and appoint .................... to be my health care agent instead.\n\nRelationship of my alternate health care agent to me:\n.\n\nTelephone number of my alternate health care agent:\n.\n\nAddress of my alternate health care agent:\n.\n\nTHIS IS WHAT I WANT MY HEALTH CARE AGENT TO BE ABLE TO\n\nDO IF I AM UNABLE TO DECIDE OR SPEAK FOR MYSELF\n\n(I know I can change these choices)\n\nMy health care agent is automatically given the powers listed below in (A) through (D). My health care agent must follow my health care instructions in this document or any other instructions I have given to my agent. If I have not given health care instructions, then my agent must act in my best interest.\n\nWhenever I am unable to decide or speak for myself, my health care agent has the power to:\n\n(A) Make any health care decision for me. This includes the power to give, refuse, or withdraw consent to any care, treatment, service, or procedures. This includes deciding whether to stop or not start health care that is keeping me or might keep me alive, and deciding about intrusive mental health treatment.\n\n(B) Choose my health care providers.\n\n(C) Choose where I live and receive care and support when those choices relate to my health care needs.\n\n(D) Review my medical records and have the same rights that I would have to give my medical records to other people.\n\nIf I DO NOT want my health care agent to have a power listed above in (A) through (D) OR if I want to LIMIT any power in (A) through (D), I MUST say that here:\n\n.\n\n.\n\n.\n\nMy health care agent is NOT automatically given the powers listed below in (1) and (2). If I WANT my agent to have any of the powers in (1) and (2), I must INITIAL the line in front of the power; then my agent WILL HAVE that power.\n\n.\n\n(1)\n\nTo decide whether to donate any parts of my body, including organs, tissues, and eyes, when I die.\n\n.\n\n(2)\n\nTo decide what will happen with my body when I die (burial, cremation).\n\nIf I want to say anything more about my health care agent's powers or limits on the powers, I can say it here:\n\n.\n\n.\n\n.\n\nPART II: HEALTH CARE INSTRUCTIONS\n\nNOTE: Complete this Part II if you wish to give health care instructions. If you appointed an agent in Part I, completing this Part II is optional but would be very helpful to your agent. However, if you chose not to appoint an agent in Part I, you MUST complete some or all of this Part II if you wish to make a valid health care directive.\n\nThese are instructions for my health care when I am unable to decide or speak for myself. These instructions must be followed (so long as they address my needs).\n\nTHESE ARE MY BELIEFS AND VALUES ABOUT MY HEALTH CARE\n\n(I know I can change these choices or leave any of them blank)\n\nI want you to know these things about me to help you make decisions about my health care:\n\nMy goals for my health care:\n.\n\n.\n\n.\n\nMy fears about my health care:\n.\n\n.\n\n.\n\nMy spiritual or religious beliefs and traditions:\n.\n\n.\n\n.\n\nMy beliefs about when life would be no longer worth living:\n.\n\n.\n\n.\n\nMy thoughts about how my medical condition might affect my family:\n.\n\n.\n\n.\n\nTHIS IS WHAT I WANT AND DO NOT WANT FOR MY HEALTH CARE\n\n(I know I can change these choices or leave any of them blank)\n\nMany medical treatments may be used to try to improve my medical condition or to prolong my life. Examples include artificial breathing by a machine connected to a tube in the lungs, artificial feeding or fluids through tubes, attempts to start a stopped heart, surgeries, dialysis, antibiotics, and blood transfusions. Most medical treatments can be tried for a while and then stopped if they do not help.\n\nI have these views about my health care in these situations:\n\n(Note: You can discuss general feelings, specific treatments, or leave any of them blank)\n\nIf I had a reasonable chance of recovery, and were temporarily unable to decide or speak for myself, I would want:\n.\n\n.\n\n.\n\nIf I were dying and unable to decide or speak for myself, I would want:\n.\n\n.\n\n.\n\nIf I were permanently unconscious and unable to decide or speak for myself, I would want:\n.\n\n.\n\n.\n\nIf I were completely dependent on others for my care and unable to decide or speak for myself, I would want:\n.\n\n.\n\n.\n\nIn all circumstances, my doctors, advanced practice registered nurses, or physician assistants will try to keep me comfortable and reduce my pain. This is how I feel about pain relief if it would affect my alertness or if it could shorten my life:\n.\n\n.\n\n.\n\nThere are other things that I want or do not want for my health care, if possible:\n\nWho I would like to be my doctor, advanced practice registered nurse, or physician assistant:\n.\n\n.\n\n.\n\nWhere I would like to live to receive health care:\n.\n\n.\n\n.\n\nWhere I would like to die and other wishes I have about dying:\n.\n\n.\n\n.\n\nMy wishes about donating parts of my body when I die:\n.\n\n.\n\n.\n\nMy wishes about what happens to my body when I die (cremation, burial):\n.\n\n.\n\n.\n\nAny other things:\n.\n\n.\n\n.\n\nPART III: MAKING THE DOCUMENT LEGAL\n\nThis document must be signed by me. It also must either be verified by a notary public (Option 1) OR witnessed by two witnesses (Option 2). It must be dated when it is verified or witnessed.\n\nI am thinking clearly, I agree with everything that is written in this document, and I have made this document willingly.\n\n.\n\nMy Signature\n\nDate signed:\n\n.\n\nDate of birth:\n\n.\n\nAddress:\n\n.\n\n.\n\nIf I cannot sign my name, I can ask someone to sign this document for me.\n\n.\n\nSignature of the person who I asked to sign this document for me.\n\n.\n\nPrinted name of the person who I asked to sign this document for me.\n\nOption 1: Notary Public\n\nIn my presence on .................... (date), ....................... (name) acknowledged his/her signature on this document or acknowledged that he/she authorized the person signing this document to sign on his/her behalf. I am not named as a health care agent or alternate health care agent in this document.\n\n.\n\n(Signature of Notary)\n(Notary Stamp)\n\nOption 2: Two Witnesses\n\nTwo witnesses must sign. Only one of the two witnesses can be a health care provider or an employee of a health care provider giving direct care to me on the day I sign this document.\n\nWitness One:\n\n(i) In my presence on ............... (date), ............... (name) acknowledged his/her signature on this document or acknowledged that he/she authorized the person signing this document to sign on his/her behalf.\n\n(ii) I am at least 18 years of age.\n\n(iii) I am not named as a health care agent or an alternate health care agent in this document.\n\n(iv) If I am a health care provider or an employee of a health care provider giving direct care to the person listed above in (A), I must initial this box: [ ]\n\nI certify that the information in (i) through (iv) is true and correct.\n\n.\n\n(Signature of Witness One)\n\nAddress:\n\n.\n\n.\n\nWitness Two:\n\n(i) In my presence on .............. (date), ................. (name) acknowledged his/her signature on this document or acknowledged that he/she authorized the person signing this document to sign on his/her behalf.\n\n(ii) I am at least 18 years of age.\n\n(iii) I am not named as a health care agent or an alternate health care agent in this document.\n\n(iv) If I am a health care provider or an employee of a health care provider giving direct care to the person listed above in (A), I must initial this box: [ ]\n\nI certify that the information in (i) through (iv) is true and correct.\n\n.\n\n(Signature of Witness Two)\n\nAddress:\n\n.\n\n.\n\nREMINDER: Keep this document with your personal papers in a safe place (not in a safe deposit box). Give signed copies to your doctors, advanced practice registered nurses, physician assistants, family, close friends, health care agent, and alternate health care agent. Make sure your doctor, advanced practice registered nurse, or physician assistant is willing to follow your wishes. This document should be part of your medical record at your physician's, advanced practice registered nurse's, or physician assistant's office and at the hospital, home care agency, hospice, or nursing facility where you receive your care.\n\nHistory:\n1998 c 399 s 24 ; 1999 c 14 s 1 ; 2020 c 115 art 4 s 72 ; 2022 c 58 s 83" + }, + { + "id": "mn-145c-05", + "jurisdiction": "MN", + "title": "Minnesota Statutes — Health Care Directive; Provisions That May Be Included", + "statuteReference": "Minn. Stat. § 145C.05", + "sourceUrl": "https://www.revisor.mn.gov/statutes/cite/145C.05", + "publisher": "Minnesota Office of the Revisor of Statutes", + "licenseNote": "U.S. state statute — public domain (Minnesota Statutes)", + "verifiedAt": "2026-06-14T00:00:00Z", + "text": "145C.05 SUGGESTED FORM; PROVISIONS THAT MAY BE INCLUDED.\n\n§\nSubdivision 1. Content.\nA health care directive executed pursuant to this chapter may, but need not, be in the form contained in section 145C.16 .\n\n§\nSubd. 2. Provisions that may be included.\n(a) A health care directive may include provisions consistent with this chapter, including, but not limited to:\n\n(1) the designation of one or more alternate health care agents to act if the named health care agent is not reasonably available to serve;\n\n(2) directions to joint health care agents regarding the process or standards by which the health care agents are to reach a health care decision for the principal, and a statement whether joint health care agents may act independently of one another;\n\n(3) limitations, if any, on the right of the health care agent or any alternate health care agents to receive, review, obtain copies of, and consent to the disclosure of the principal's medical records or to visit the principal when the principal is a patient in a health care facility;\n\n(4) limitations, if any, on the nomination of the health care agent as guardian for purposes of sections 524.5-202 , 524.5-211 , 524.5-302 , and 524.5-303 ;\n\n(5) a document of gift for the purpose of making an anatomical gift, as set forth in chapter 525A, or an amendment to, revocation of, or refusal to make an anatomical gift;\n\n(6) a declaration regarding intrusive mental health treatment under section 253B.03, subdivision 6d , or a statement that the health care agent is authorized to give consent for the principal under section 253B.04, subdivision 1a ;\n\n(7) a funeral directive as provided in section 149A.80, subdivision 2 ;\n\n(8) limitations, if any, to the effect of dissolution or annulment of marriage or termination of domestic partnership on the appointment of a health care agent under section 145C.09, subdivision 2 ;\n\n(9) specific reasons why a principal wants a health care provider or an employee of a health care provider attending the principal to be eligible to act as the principal's health care agent;\n\n(10) health care instructions by a woman of child bearing age regarding how she would like her pregnancy, if any, to affect health care decisions made on her behalf;\n\n(11) health care instructions regarding artificially administered nutrition or hydration; and\n\n(12) health care instructions to prohibit administering, dispensing, or prescribing an opioid, except that these instructions must not be construed to limit the administering, dispensing, or prescribing an opioid to treat substance abuse, opioid dependence, or an overdose, unless otherwise prohibited in the health care directive.\n\n(b) A health care directive may include a statement of the circumstances under which the directive becomes effective other than upon the judgment of the principal's attending physician, advanced practice registered nurse, or physician assistant in the following situations:\n\n(1) a principal who in good faith generally selects and depends upon spiritual means or prayer for the treatment or care of disease or remedial care and does not have an attending physician, advanced practice registered nurse, or physician assistant, may include a statement appointing an individual who may determine the principal's decision-making capacity; and\n\n(2) a principal who in good faith does not generally select a physician, advanced practice registered nurse, or physician assistant or a health care facility for the principal's health care needs may include a statement appointing an individual who may determine the principal's decision-making capacity, provided that if the need to determine the principal's capacity arises when the principal is receiving care under the direction of an attending physician, advanced practice registered nurse, or physician assistant in a health care facility, the determination must be made by an attending physician, advanced practice registered nurse, or physician assistant after consultation with the appointed individual.\n\nIf a person appointed under clause (1) or (2) is not reasonably available and the principal is receiving care under the direction of an attending physician, advanced practice registered nurse, or physician assistant in a health care facility, an attending physician, advanced practice registered nurse, or physician assistant shall determine the principal's decision-making capacity.\n\n(c) A health care directive may authorize a health care agent to make health care decisions for a principal even though the principal retains decision-making capacity.\n\nHistory:\n1993 c 312 s 6 ; 1995 c 211 s 2 ; 1998 c 399 s 15 ,16; 2004 c 146 art 3 s 3 ; 2007 c 120 art 2 s 4 ; 2007 c 147 art 9 s 22 ; 2019 c 63 art 2 s 1 ; 2020 c 115 art 4 s 69 ; 2022 c 58 s 80" + } + ] +} diff --git a/docs/legal-corpus/manifest.md b/docs/legal-corpus/manifest.md index 0d27a5a..4c13124 100644 --- a/docs/legal-corpus/manifest.md +++ b/docs/legal-corpus/manifest.md @@ -22,10 +22,42 @@ GA corpus sources must be official statute, regulation, or court-rule publishers | Jurisdiction | Source family | Status | |---|---|---| -| IL | 755 ILCS 5, 755 ILCS 35, 755 ILCS 45, 760 ILCS 3, Cook County probate rules | pending ingest | +| IL | 755 ILCS 5, 755 ILCS 35, 755 ILCS 45, 760 ILCS 3, Cook County probate rules | first set sourced (5/4-3, 45/3-3, 45/4-10) — see below | | US | 26 USC 2001-2058, HIPAA Privacy Rule, ABA Model Rules excerpts | pending ingest | -| MD | Health-General 5-601 through 5-618 | pending ingest | -| MN | Chapter 145C and relevant guardianship/conservatorship references | pending ingest | +| MD | Health-General 5-601 through 5-618 | first set sourced (HG § 5-603) — see below | +| MN | Chapter 145C and relevant guardianship/conservatorship references | first set sourced (507.071, 145C.05, 145C.16) — see below | + +### Sourced forms — launch states (manifest: `launch-states.json`) + +First obvious estate-planning forms for IL/MD/MN, sourced **verbatim** from official +state legislature / revisor publishers only (Rule 9). Captured via deterministic +HTML-to-text extraction (no LLM in the text path); each source URL is recorded per +chunk by the ingestion pipeline. Retrieved/verified **2026-06-14**. + +| id | Juris | Citation | Form | Official source | Chunks | +|---|---|---|---|---|---| +| `il-755-ilcs-5-4-3` | IL | 755 ILCS 5/4-3 | Probate Act — will signing & attestation | [ilga.gov](https://www.ilga.gov/documents/legislation/ilcs/documents/075500050K4-3.htm) | 1 | +| `il-755-ilcs-45-3-3` | IL | 755 ILCS 45/3-3 | POA Act — statutory short form POA for **property** | [ilga.gov](https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K3-3.htm) | 13 | +| `il-755-ilcs-45-4-10` | IL | 755 ILCS 45/4-10 | POA Act — statutory short form POA for **health care** | [ilga.gov](https://www.ilga.gov/documents/legislation/ilcs/documents/075500450K4-10.htm) | 15 | +| `md-hg-5-603` | MD | Md. Code, Health-General § 5-603 | Advance directive statutory form | [mgaleg.maryland.gov](https://mgaleg.maryland.gov/mgawebsite/Laws/StatuteText?article=ghg§ion=5-603&enactments=false) | 10 | +| `mn-507-071` | MN | Minn. Stat. § 507.071 | Transfer-on-death deed (TODD) + form | [revisor.mn.gov](https://www.revisor.mn.gov/statutes/cite/507.071) | 16 | +| `mn-145c-05` | MN | Minn. Stat. § 145C.05 | Health care directive — provisions that may be included | [revisor.mn.gov](https://www.revisor.mn.gov/statutes/cite/145C.05) | 3 | +| `mn-145c-16` | MN | Minn. Stat. § 145C.16 | Health care directive — suggested form | [revisor.mn.gov](https://www.revisor.mn.gov/statutes/cite/145C.16) | 6 | + +Dry-run (`go run ./cmd/corpus-ingest -manifest ../docs/legal-corpus/launch-states.json -dry-run`): +**7 sources, 64 chunks — PASS** (all sources validated; nothing embedded or written). + +**Gaps / not yet sourced (Rule 9 — recorded, not invented):** +- IL has **no fill-in-the-blank statutory will form** in 755 ILCS 5 (unlike CA Prob. Code). + Will validity is governed by execution requirements (755 ILCS 5/4-3, sourced). A + full self-proving-will template would come from court/forms sources, not a statute — + deferred pending owner/legal direction. +- MN statutory **will** is likewise not a single fill-in form; MN intestacy/will rules + live in 524.2-xxx (Uniform Probate Code). Only the TODD + health-care-directive forms + are sourced in this first set. +- MD POA / estates-&-trusts statutory forms (Est. & Trusts Title 17) and MD additional + advance-directive variants are not yet sourced — next batch. +- US federal estate tax (26 USC 2001) and HIPAA excerpts remain pending ingest. ## Ingestion (`api/cmd/corpus-ingest`) diff --git a/firestore.rules b/firestore.rules index 04cdb8f..c842f07 100644 --- a/firestore.rules +++ b/firestore.rules @@ -39,15 +39,23 @@ service cloud.firestore { match /users/{userId} { allow read: if isOwner(userId); - // Registration: user creates own profile with required fields + // Registration: user creates own profile with required fields. + // Defense-in-depth (invitation-seizure): the profile email MUST equal the + // verified token email. The Cloud Function now resolves identity via + // admin.auth().getUser, but pinning the email here too prevents a user from + // ever persisting a profile email they don't actually own — closing every + // downstream code path that still reads users/{uid}.email. allow create: if isOwner(userId) && request.resource.data.keys().hasAll(['email', 'role', 'createdAt']) + && request.resource.data.email == request.auth.token.email && request.resource.data.role in ['principal', 'executor', 'heir', 'trustee', 'legal', 'cpa']; - - // Self-update: cannot change role or createdAt + + // Self-update: cannot change role, createdAt, or email. Email is identity — + // it is set once at registration to the verified token email and is never + // client-mutable thereafter (an executor/heir role-seizure vector). allow update: if (isOwner(userId) && !request.resource.data.diff(resource.data).affectedKeys() - .hasAny(['role', 'createdAt'])) + .hasAny(['role', 'createdAt', 'email'])) || isAdmin(); // Only admins can delete users diff --git a/functions/index.js b/functions/index.js index 8d9f42c..212d632 100644 --- a/functions/index.js +++ b/functions/index.js @@ -6,9 +6,17 @@ * that cannot run on Cloud Run. * * Functions: - * 1. autoMatchInvitation — Match pending invitations to new users - * 2. sendMail — Send transactional email via Gmail API (Google-native) - * 3. sendSMS — Process SMS queue for invitation notifications + * 1. autoMatchInvitation — Match pending invitations to new users (on signup) + * 1b. autoMatchOnInvitation — Link invitees who already have an account (on invite) + * 2. sendMail — Send transactional email via Gmail API (Google-native) + * 3. guardianInactivityCheck — Daily Guardian Protocol inactivity sweep (scheduled) + * + * NOTE: There is intentionally NO SMS function. Invitation delivery is email-only + * (the Gmail rail above). A phone-number/SMS path was deliberately removed rather + * than ship a stub that silently never delivers a text — the UI must not promise a + * channel that does not exist. When a real SMS provider is adopted (Sirsi Sign + * shared rail per ADR-047, or a managed Twilio extension), reintroduce a delivery + * function here AND re-expose the phone field in InviteTeamMember.tsx together. */ const { onDocumentCreated } = require('firebase-functions/v2/firestore'); @@ -112,12 +120,38 @@ exports.autoMatchInvitation = onDocumentCreated( const snapshot = event.data; if (!snapshot) return; - const userData = snapshot.data(); const uid = event.params.uid; - const email = (userData.email || '').toLowerCase().trim(); + // CRITICAL — verified identity ONLY. The users/{uid} document is + // client-writable, so an attacker can verify an account they control and + // then write users/{uid}.email = 'victim-invited@...' to seize the + // executor/heir role attached to that invitation. We MUST NOT trust + // userData.email. Instead resolve the email from the Firebase Auth record + // (admin.auth().getUser) — the identity the user actually proved — and + // refuse to grant any estate access until that email is verified. This is + // the write-side mirror of the firestore.rules isEstateRole() email_verified + // gate; without it the junction is written off a bare email-string match. + let rec; + try { + rec = await admin.auth().getUser(uid); + } catch (e) { + console.warn(`[autoMatch] admin.auth().getUser(${uid}) failed: ${e.message || e}`); + return; + } + + const email = (rec.email || '').toLowerCase().trim(); if (!email) return; + // Defense-in-depth: never grant estate access off an unverified address. + // The attacker can never VERIFY an invited address they don't control, so + // gating here makes the seizure path unreachable while a legitimate invitee + // is matched the moment they verify (this trigger re-runs on profile create, + // and the autoMatchOnInvitation path also covers already-verified users). + if (!rec.emailVerified) { + console.log(`[autoMatch] Skipping ${email} (uid: ${uid}) — email not verified`); + return; + } + console.log(`[autoMatch] Checking invitations for ${email} (uid: ${uid})`); try { @@ -538,112 +572,7 @@ exports.sendMail = onDocumentCreated( } ); -// ─── 3. Send SMS — Notification Queue ──────────────────────────────────── -// -// Watches the 'sms_queue' Firestore collection. When a document is created, -// processes the SMS request and updates delivery status. -// -// Document schema (written by web/src/lib/invitations.ts): -// { -// to: "+15551234567", -// body: "You've been invited to an estate on FinalWishes...", -// invitationId: "abc123", -// estateId: "estate456", -// status: "pending", -// createdBy: "uid789", -// createdAt: Timestamp -// } -// -// TODO: Integrate a real SMS provider. Options (Google-first priority): -// 1. Google Cloud Communication API (when GA) — preferred, native GCP -// 2. Firebase Extensions: "Send Messages with Twilio" — managed, low-code -// 3. Direct Twilio SDK — fallback if Google options unavailable -// -// Until a provider is configured, this function validates the request, -// logs the SMS details for manual review, and updates delivery status. - -exports.sendSMS = onDocumentCreated( - { - document: 'sms_queue/{smsId}', - memory: '256MiB', - timeoutSeconds: 30, - }, - async (event) => { - const snapshot = event.data; - if (!snapshot) return; - - const smsData = snapshot.data(); - const smsRef = snapshot.ref; - - // Skip if already processed - if (smsData.delivery?.state === 'SUCCESS' || smsData.delivery?.state === 'ERROR') return; - - try { - const { to, body, invitationId, estateId, createdBy } = smsData; - - // Validate required fields - if (!to || !body) { - await smsRef.update({ - 'delivery.state': 'ERROR', - 'delivery.error': 'Missing required fields: to and body are required', - 'delivery.endTime': admin.firestore.FieldValue.serverTimestamp(), - }); - return; - } - - // Validate phone number format (E.164: +[country][number]) - const e164Regex = /^\+[1-9]\d{6,14}$/; - if (!e164Regex.test(to.replace(/[\s\-()]/g, ''))) { - await smsRef.update({ - 'delivery.state': 'ERROR', - 'delivery.error': `Invalid phone number format: ${to}. Expected E.164 format (e.g., +15551234567)`, - 'delivery.endTime': admin.firestore.FieldValue.serverTimestamp(), - }); - return; - } - - // Validate body length (SMS limit is 160 chars for single segment) - if (body.length > 1600) { - await smsRef.update({ - 'delivery.state': 'ERROR', - 'delivery.error': `SMS body too long (${body.length} chars). Maximum 1600 characters.`, - 'delivery.endTime': admin.firestore.FieldValue.serverTimestamp(), - }); - return; - } - - // TODO: Replace this block with actual SMS provider integration. - // For now, log the request for manual processing / audit trail. - console.log(`[sendSMS] SMS queued for delivery:`, { - to, - bodyLength: body.length, - invitationId: invitationId || 'none', - estateId: estateId || 'none', - createdBy: createdBy || 'unknown', - }); - - // Mark as sent (provider pending — update to real delivery once integrated) - await smsRef.update({ - 'delivery.state': 'PENDING_PROVIDER', - 'delivery.info': 'SMS queued successfully. Awaiting SMS provider integration.', - 'delivery.endTime': admin.firestore.FieldValue.serverTimestamp(), - 'delivery.leaseExpireTime': null, - }); - - console.log(`[sendSMS] Queued SMS to ${to} (invitation: ${invitationId || 'N/A'})`); - } catch (error) { - console.error(`[sendSMS] Error:`, error); - - await smsRef.update({ - 'delivery.state': 'ERROR', - 'delivery.error': error.message || 'Unknown SMS processing error', - 'delivery.endTime': admin.firestore.FieldValue.serverTimestamp(), - }); - } - } -); - -// ─── 4. Guardian Protocol — Daily Inactivity Check ────────────────────── +// ─── 3. Guardian Protocol — Daily Inactivity Check ────────────────────── // Runs every day at 6 AM EST. Calls the Go API's inactivity check endpoint // which escalates estates where the owner has gone silent. // diff --git a/functions/index.test.js b/functions/index.test.js index 62b058d..7404610 100644 --- a/functions/index.test.js +++ b/functions/index.test.js @@ -36,6 +36,11 @@ jest.mock('firebase-admin', () => ({ })), })); +// admin.auth() handles used by sendMail's open-relay defense (getUser) and the +// guardian scheduler (createCustomToken). getUser is overridable per-test via +// mockGetUser so a fixture can simulate the createdBy account's own email. +const mockGetUser = jest.fn().mockResolvedValue({ email: '' }); + // Make FieldValue accessible jest.mock('firebase-admin', () => { const admin = { @@ -45,6 +50,7 @@ jest.mock('firebase-admin', () => { }), auth: jest.fn(() => ({ createCustomToken: jest.fn().mockResolvedValue('mock-custom-token'), + getUser: mockGetUser, })), }; return admin; @@ -131,6 +137,11 @@ describe('autoMatchInvitation', () => { beforeEach(() => { jest.clearAllMocks(); mockDb.batch.mockReturnValue(mockBatch); + // Default identity: a VERIFIED account whose Firebase Auth email is the one we + // match invitations against. The handler no longer trusts the client-writable + // users/{uid}.email — it resolves identity via admin.auth().getUser(uid). Tests + // override mockGetUser to simulate verified/unverified/seizure fixtures. + mockGetUser.mockResolvedValue({ email: 'test@example.com', emailVerified: true }); }); test('skips when no snapshot data', async () => { @@ -138,13 +149,60 @@ describe('autoMatchInvitation', () => { expect(mockCollection).not.toHaveBeenCalled(); }); - test('skips when user has no email', async () => { - const event = makeEvent({ email: '' }, { uid: 'u1' }); + test('skips when auth record has no email', async () => { + mockGetUser.mockResolvedValue({ email: '', emailVerified: false }); + const event = makeEvent({ email: 'spoofed@victim.com' }, { uid: 'u1' }); + await handler(event); + expect(mockCollection).not.toHaveBeenCalled(); + }); + + test('skips when admin.auth().getUser throws (no fabricated identity)', async () => { + mockGetUser.mockRejectedValueOnce(new Error('user not found')); + const event = makeEvent({ email: 'spoofed@victim.com' }, { uid: 'u1' }); + await handler(event); + expect(mockCollection).not.toHaveBeenCalled(); + }); + + // ─── CRITICAL: invitation-seizure defense ──────────────────────────────── + // An attacker verifies an account they control, then writes + // users/{uid}.email = 'victim-invited@...' to seize that invitation's role. + // The handler must IGNORE userData.email entirely and gate on the VERIFIED + // auth email — so an unverified account grants nothing even with a forged + // profile email, and a verified account is only ever matched against its OWN + // verified address. + test('SEIZURE DEFENSE: ignores client profile email when account is unverified', async () => { + // Auth record: attacker controls attacker@evil.com but has NOT verified it. + mockGetUser.mockResolvedValue({ email: 'attacker@evil.com', emailVerified: false }); + const event = makeEvent({ email: 'victim-invited@example.com' }, { uid: 'attacker-uid' }); await handler(event); + // No invitation query, no junction write — access denied. expect(mockCollection).not.toHaveBeenCalled(); + expect(mockBatch.commit).not.toHaveBeenCalled(); + }); + + test('SEIZURE DEFENSE: matches the VERIFIED auth email, never the profile email', async () => { + // Attacker verified their OWN address but forged the profile email to the + // victim's invited address. The handler must query invitations for the + // VERIFIED address (attacker@evil.com), NOT the forged victim address. + mockGetUser.mockResolvedValue({ email: 'attacker@evil.com', emailVerified: true }); + mockCollection.mockReturnValue({ + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, size: 0, docs: [] }), + }); + + const event = makeEvent({ email: 'victim-invited@example.com' }, { uid: 'attacker-uid' }); + await handler(event); + + const whereCalls = mockCollection().where.mock.calls; + expect(whereCalls[0]).toEqual(['email', '==', 'attacker@evil.com']); + // The forged victim email must NEVER appear in any query. + for (const call of whereCalls) { + expect(call).not.toContain('victim-invited@example.com'); + } }); test('skips when no pending invitations found', async () => { + mockGetUser.mockResolvedValue({ email: 'test@example.com', emailVerified: true }); mockCollection.mockReturnValue({ where: jest.fn().mockReturnThis(), get: jest.fn().mockResolvedValue({ empty: true, size: 0, docs: [] }), @@ -165,6 +223,16 @@ describe('autoMatchInvitation', () => { const mockWhere = jest.fn().mockReturnThis(); const mockGetResult = { empty: false, size: 1, docs: [invDoc] }; + // An empty role/soul-log query result: the handler issues a SECOND query + // against estates//heirs|executors (to flip the subcollection doc to + // 'active') and estates//soul-log (sharedWith backfill). The mock must + // return a chainable .where(...).get() that yields no docs so those queries + // resolve cleanly instead of throwing "db.collection(...).where is not a + // function" (which the handler's try/catch would silently swallow). + const emptyQuery = () => ({ + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, size: 0, docs: [], forEach: () => {} }), + }); mockCollection.mockImplementation((collName) => { if (collName === 'estate_invitations') { return { where: mockWhere, get: jest.fn().mockResolvedValue(mockGetResult) }; @@ -175,6 +243,11 @@ describe('autoMatchInvitation', () => { if (collName === 'audit_logs') { return { doc: jest.fn(() => ({ id: 'audit-ref' })) }; } + // estates//heirs, estates//executors, estates//soul-log + if (typeof collName === 'string' && + (collName.includes('/heirs') || collName.includes('/executors') || collName.includes('/soul-log'))) { + return emptyQuery(); + } return { doc: mockDoc }; }); @@ -187,13 +260,15 @@ describe('autoMatchInvitation', () => { expect(mockBatch.commit).toHaveBeenCalledTimes(1); }); - test('normalizes email to lowercase and trims whitespace', async () => { + test('normalizes the VERIFIED auth email to lowercase and trims whitespace', async () => { + // Normalization applies to the auth-record email, not the client profile. + mockGetUser.mockResolvedValue({ email: ' Test@EXAMPLE.com ', emailVerified: true }); mockCollection.mockReturnValue({ where: jest.fn().mockReturnThis(), get: jest.fn().mockResolvedValue({ empty: true, size: 0, docs: [] }), }); - const event = makeEvent({ email: ' Test@EXAMPLE.com ' }, { uid: 'u1' }); + const event = makeEvent({ email: 'unused-profile@example.com' }, { uid: 'u1' }); await handler(event); // The where clause should have been called with lowercase trimmed email @@ -205,8 +280,47 @@ describe('autoMatchInvitation', () => { describe('sendMail', () => { const handler = registeredFunctions['mail/{mailId}']; + // Wire the Firestore mock so the open-relay defense (isRecipientAuthorized) + // AUTHORIZES every recipient for `createdBy`, via path (a): the createdBy user + // belongs to estate `est-1` (estate_users) and the recipient has a pending + // invitation for that same estate (estate_invitations by email). Any test that + // expects a successful send must call this first; the production defense is NOT + // weakened — the test simply provides the data the defense looks for. + function authorizeAllRecipients(estateId = 'est-1') { + mockCollection.mockImplementation((collName) => { + if (collName === 'estate_users') { + return { + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ data: () => ({ estateId, userId: 'owner-1' }) }], + }), + }; + } + if (collName === 'estate_invitations') { + return { + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ data: () => ({ estateId, email: 'authorized' }) }], + }), + }; + } + if (collName === 'email_templates') { + return { doc: mockDoc }; + } + // heirs/executors fallback (path c) — not reached when (a) matches first. + return { + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [] }), + doc: mockDoc, + }; + }); + } + beforeEach(() => { jest.clearAllMocks(); + mockGetUser.mockResolvedValue({ email: '' }); + authorizeAllRecipients(); }); test('skips when no snapshot data', async () => { @@ -264,6 +378,7 @@ describe('sendMail', () => { data: () => ({ to: 'recipient@example.com', message: { subject: 'Welcome', html: '

Hello

' }, + createdBy: 'owner-1', }), ref, }, @@ -297,6 +412,7 @@ describe('sendMail', () => { data: () => ({ to: ['a@b.com', 'c@d.com'], message: { subject: 'Multi', html: '

Hi all

' }, + createdBy: 'owner-1', }), ref, }, @@ -314,6 +430,8 @@ describe('sendMail', () => { test('resolves template from email_templates collection', async () => { const ref = { update: jest.fn().mockResolvedValue(undefined) }; + // Authorize the recipient AND serve the template doc from the same mock so + // the open-relay lookups (estate_users / estate_invitations) still resolve. mockCollection.mockImplementation((collName) => { if (collName === 'email_templates') { return { @@ -328,13 +446,35 @@ describe('sendMail', () => { })), }; } - return { doc: mockDoc }; + if (collName === 'estate_users') { + return { + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ data: () => ({ estateId: 'est-1', userId: 'owner-1' }) }], + }), + }; + } + if (collName === 'estate_invitations') { + return { + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ + docs: [{ data: () => ({ estateId: 'est-1', email: 'heir@test.com' }) }], + }), + }; + } + return { + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [] }), + doc: mockDoc, + }; }); const event = { data: { data: () => ({ to: 'heir@test.com', + createdBy: 'owner-1', template: { name: 'invitation', data: { name: 'John', estate: 'Collymore Estate' }, @@ -362,6 +502,7 @@ describe('sendMail', () => { data: () => ({ to: 'a@b.com', message: { subject: 'Test', html: '

Hi

' }, + createdBy: 'owner-1', }), ref, }, @@ -377,149 +518,93 @@ describe('sendMail', () => { }) ); }); -}); - -describe('sendSMS', () => { - const handler = registeredFunctions['sms_queue/{smsId}']; - beforeEach(() => { - jest.clearAllMocks(); - }); + // ─── Open-relay defense (locks in the createdBy-attribution hardening) ───── - test('skips when no snapshot data', async () => { - await handler({ data: null, params: { smsId: 's1' } }); - expect(mockUpdate).not.toHaveBeenCalled(); - }); - - test('skips already processed', async () => { - const event = makeEvent({ delivery: { state: 'SUCCESS' } }, { smsId: 's1' }); - await handler(event); - // Should not have called update since delivery was already SUCCESS - }); - - test('errors on missing required fields (no phone)', async () => { - const ref = { update: jest.fn().mockResolvedValue(undefined) }; - const event = { - data: { data: () => ({ body: 'Hello' }), ref }, - params: { smsId: 's1' }, - }; - - await handler(event); - - expect(ref.update).toHaveBeenCalledWith( - expect.objectContaining({ - 'delivery.state': 'ERROR', - 'delivery.error': expect.stringContaining('Missing required fields'), - }) - ); - }); - - test('errors on missing required fields (no body)', async () => { - const ref = { update: jest.fn().mockResolvedValue(undefined) }; - const event = { - data: { data: () => ({ to: '+15551234567' }), ref }, - params: { smsId: 's1' }, - }; - - await handler(event); - - expect(ref.update).toHaveBeenCalledWith( - expect.objectContaining({ - 'delivery.state': 'ERROR', - 'delivery.error': expect.stringContaining('Missing required fields'), - }) - ); - }); - - test('errors on invalid phone number format', async () => { - const ref = { update: jest.fn().mockResolvedValue(undefined) }; - const event = { - data: { - data: () => ({ to: '555-123-4567', body: 'Hello' }), - ref, - }, - params: { smsId: 's1' }, - }; - - await handler(event); - - expect(ref.update).toHaveBeenCalledWith( - expect.objectContaining({ - 'delivery.state': 'ERROR', - 'delivery.error': expect.stringContaining('Invalid phone number format'), - }) - ); - }); - - test('errors on body exceeding 1600 characters', async () => { - const ref = { update: jest.fn().mockResolvedValue(undefined) }; - const event = { - data: { - data: () => ({ to: '+15551234567', body: 'A'.repeat(1601) }), - ref, - }, - params: { smsId: 's1' }, - }; - - await handler(event); - - expect(ref.update).toHaveBeenCalledWith( - expect.objectContaining({ - 'delivery.state': 'ERROR', - 'delivery.error': expect.stringContaining('SMS body too long'), - }) - ); - }); - - test('queues valid SMS with PENDING_PROVIDER status', async () => { + test('rejects mail with NO createdBy attribution (open-relay defense)', async () => { const ref = { update: jest.fn().mockResolvedValue(undefined) }; const event = { data: { data: () => ({ - to: '+15551234567', - body: 'You have been invited to an estate.', - invitationId: 'inv-1', - estateId: 'est-1', - createdBy: 'uid-1', + to: 'stranger@example.com', + message: { subject: 'Phish', html: '

Click me

' }, + // createdBy intentionally absent }), ref, }, - params: { smsId: 's1' }, + params: { mailId: 'm1' }, }; await handler(event); + // Must FAIL CLOSED — never relay an unattributed mail from admin@sirsi.ai. + expect(mockGmailSend).not.toHaveBeenCalled(); expect(ref.update).toHaveBeenCalledWith( expect.objectContaining({ - 'delivery.state': 'PENDING_PROVIDER', - 'delivery.info': expect.stringContaining('SMS queued successfully'), + 'delivery.state': 'ERROR', + 'delivery.error': 'recipient not authorized', + status: 'rejected', }) ); }); - test('accepts E.164 phone with spaces/dashes stripped', async () => { + test('rejects mail to a recipient the createdBy user is NOT authorized for', async () => { + // createdBy belongs to NO estate and the recipient is not their own email → + // every authorization path fails and the mail must be rejected. + mockGetUser.mockResolvedValue({ email: 'owner@sirsi.ai' }); + mockCollection.mockImplementation((collName) => { + if (collName === 'estate_users') { + return { + where: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ docs: [] }), // belongs to no estate + }; + } + if (collName === 'users') { + return { + doc: jest.fn(() => ({ + get: jest.fn().mockResolvedValue({ exists: false }), + })), + }; + } + return { + where: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + get: jest.fn().mockResolvedValue({ empty: true, docs: [] }), + doc: mockDoc, + }; + }); + const ref = { update: jest.fn().mockResolvedValue(undefined) }; const event = { data: { data: () => ({ - to: '+1 (555) 123-4567', - body: 'Test message', + to: 'victim@example.com', + message: { subject: 'Hi', html: '

Hi

' }, + createdBy: 'orphan-uid', }), ref, }, - params: { smsId: 's1' }, + params: { mailId: 'm1' }, }; await handler(event); + expect(mockGmailSend).not.toHaveBeenCalled(); expect(ref.update).toHaveBeenCalledWith( expect.objectContaining({ - 'delivery.state': 'PENDING_PROVIDER', + 'delivery.state': 'ERROR', + 'delivery.error': 'recipient not authorized', + status: 'rejected', }) ); }); }); +// NOTE: There is no sendSMS describe block. Invitation delivery is email-only; +// the SMS queue + stub delivery function were removed (it never delivered a text +// while the UI reported success). Reinstate tests here only alongside a live SMS +// provider integration. + describe('guardianInactivityCheck', () => { const handler = registeredFunctions['__schedule__']; diff --git a/functions/package.json b/functions/package.json index 89683f9..448ff49 100644 --- a/functions/package.json +++ b/functions/package.json @@ -15,6 +15,7 @@ "scripts": { "lint": "echo 'No lint configured'", "test": "jest --verbose", + "test:ci": "npm ci && jest --ci --verbose", "serve": "firebase emulators:start --only functions", "deploy": "firebase deploy --only functions" }, diff --git a/package-lock.json b/package-lock.json index c23574f..80f59b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -932,9 +932,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -949,9 +949,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -966,9 +966,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -983,9 +983,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1000,9 +1000,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1017,9 +1017,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1034,9 +1034,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1051,9 +1051,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1068,9 +1068,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1085,9 +1085,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1102,9 +1102,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1119,9 +1119,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1136,9 +1136,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1153,9 +1153,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1170,9 +1170,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1187,9 +1187,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1204,9 +1204,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1221,9 +1221,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1238,9 +1238,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1255,9 +1255,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -1272,9 +1272,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -1289,9 +1289,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1306,9 +1306,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1323,9 +1323,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1340,9 +1340,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1357,9 +1357,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2194,9 +2194,9 @@ } }, "node_modules/@grpc/grpc-js": { - "version": "1.9.15", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", - "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "version": "1.9.16", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.16.tgz", + "integrity": "sha512-wE4Ut/olIzfKqp631XrG+wbF0v1vWFN4YL9FyXC2LJiG33DsV7PLzURjrCvY/6je2ntdRkeLpPDluzSRGaVltQ==", "license": "Apache-2.0", "dependencies": { "@grpc/proto-loader": "^0.7.8", @@ -6894,6 +6894,106 @@ "dequal": "^2.0.3" } }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -6916,6 +7016,23 @@ "node": ">=4" } }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/attr-accept": { "version": "2.2.5", "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", @@ -6925,6 +7042,42 @@ "node": ">=4" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/babel-dead-code-elimination": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", @@ -7121,6 +7274,25 @@ "node": ">= 0.8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -7679,6 +7851,13 @@ "node": ">=12" } }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -7702,6 +7881,60 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7790,6 +8023,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -7802,6 +8053,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -7990,6 +8259,75 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -8016,9 +8354,9 @@ "license": "MIT" }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -8027,6 +8365,53 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -8038,9 +8423,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -8051,32 +8436,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -8167,6 +8552,53 @@ } } }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "array-includes": "^3.1.8", + "array.prototype.flatmap": "^1.3.2", + "ast-types-flow": "^0.0.8", + "axe-core": "^4.10.0", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "emoji-regex": "^9.2.2", + "hasown": "^2.0.2", + "jsx-ast-utils": "^3.3.5", + "language-tags": "^1.0.9", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "safe-regex-test": "^1.0.3", + "string.prototype.includes": "^2.0.1" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", @@ -8789,13 +9221,29 @@ "unicode-trie": "^2.0.0" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", "dependencies": { - "fetch-blob": "^3.1.2" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" }, "engines": { "node": ">=12.20.0" @@ -8884,12 +9332,56 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fuzzysort": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", "license": "MIT" }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8994,17 +9486,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/glob-parent": { @@ -9032,6 +9529,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/goober": { "version": "2.1.18", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", @@ -9069,6 +9583,19 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -9079,6 +9606,35 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -9091,10 +9647,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -9310,6 +9882,21 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -9337,12 +9924,66 @@ "node": ">= 0.10" } }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "license": "MIT" }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -9356,6 +9997,71 @@ "node": ">=8" } }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -9371,6 +10077,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -9380,6 +10102,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -9389,6 +10127,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -9443,6 +10201,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-node-process": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", @@ -9458,6 +10242,23 @@ "node": ">=0.12.0" } }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-obj": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-3.0.0.tgz", @@ -9495,6 +10296,25 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-regexp": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-3.1.0.tgz", @@ -9507,6 +10327,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -9519,6 +10368,57 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -9537,6 +10437,52 @@ "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", "license": "MIT" }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -9745,6 +10691,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -9776,6 +10738,26 @@ "node": ">=6" } }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -10611,6 +11593,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/object-treeify": { "version": "1.1.33", "resolved": "https://registry.npmjs.org/object-treeify/-/object-treeify-1.1.33.tgz", @@ -10620,6 +11612,65 @@ "node": ">= 10" } }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -10764,6 +11815,24 @@ "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", "license": "MIT" }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -10981,6 +12050,16 @@ "fflate": "^0.8.2" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -11740,6 +12819,50 @@ "redux": "^5.0.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -11773,16 +12896,6 @@ "node": ">=4" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -11919,12 +13032,81 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-array-concat/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -12031,6 +13213,55 @@ "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -12269,6 +13500,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/strict-event-emitter": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", @@ -12301,6 +13546,81 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -12634,14 +13954,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -12737,6 +14056,84 @@ "node": ">= 0.6" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -12774,6 +14171,25 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici": { "version": "7.25.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", @@ -13320,6 +14736,102 @@ "node": ">= 8" } }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -13663,6 +15175,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.2", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^14.0.0", diff --git a/scripts/backfill-estate-principalid.js b/scripts/backfill-estate-principalid.js new file mode 100644 index 0000000..fabbaee --- /dev/null +++ b/scripts/backfill-estate-principalid.js @@ -0,0 +1,68 @@ +#!/usr/bin/env node +/** + * Backfill estates/{id}.principalId for legacy estates created before the field + * existed. The OpenSign signer=principal change (PR #10) resolves the signer from + * estates/{id}.principalId; an estate missing it now 400s on signing. This sets + * principalId from the authoritative role=="principal" estate_users junction. + * + * DRY RUN by default (read-only: reports counts, writes nothing). + * Apply with --apply. + * + * Usage: + * GOOGLE_APPLICATION_CREDENTIALS=~/.config/gcloud/finalwishes-claude-agent.json \ + * GCLOUD_PROJECT=finalwishes-prod node scripts/backfill-estate-principalid.js [--apply] + * + * Refs: ADR-047, claude-home signer=principal decision 2026-06-14 + */ +const path = require('path'); +const admin = require(path.join(__dirname, '..', 'functions', 'node_modules', 'firebase-admin')); + +const APPLY = process.argv.includes('--apply'); +admin.initializeApp({ projectId: process.env.GCLOUD_PROJECT || 'finalwishes-prod' }); +const db = admin.firestore(); + +(async () => { + const estatesSnap = await db.collection('estates').get(); + let total = 0, hasField = 0, backfilled = 0, unresolved = 0, ambiguous = 0; + const unresolvedIds = []; + + for (const estate of estatesSnap.docs) { + total++; + const data = estate.data(); + if (data.principalId) { hasField++; continue; } + + // Resolve the principal from the authoritative junction. + const principals = await db.collection('estate_users') + .where('estateId', '==', estate.id) + .where('role', '==', 'principal') + .get(); + + if (principals.empty) { unresolved++; unresolvedIds.push(estate.id); continue; } + if (principals.size > 1) { + // More than one principal junction — ambiguous, do NOT guess. + ambiguous++; unresolvedIds.push(estate.id + ' (ambiguous: ' + principals.size + ' principals)'); continue; + } + const principalId = principals.docs[0].data().userId; + if (!principalId) { unresolved++; unresolvedIds.push(estate.id + ' (junction has no userId)'); continue; } + + if (APPLY) { + await estate.ref.update({ principalId, updatedAt: admin.firestore.FieldValue.serverTimestamp() }); + } + backfilled++; + console.log(`${APPLY ? 'SET' : 'WOULD SET'} estates/${estate.id}.principalId = ${principalId}`); + } + + console.log('\n--- backfill summary ---'); + console.log(`mode: ${APPLY ? 'APPLY (writes)' : 'DRY RUN (no writes)'}`); + console.log(`estates total: ${total}`); + console.log(`already had field: ${hasField}`); + console.log(`backfilled: ${backfilled}`); + console.log(`unresolved (no junction / no userId): ${unresolved}`); + console.log(`ambiguous (>1 principal): ${ambiguous}`); + if (unresolvedIds.length) { + console.log('\nEstates needing manual attention:'); + unresolvedIds.forEach((id) => console.log(' - ' + id)); + } + if (!APPLY && backfilled > 0) console.log('\nRe-run with --apply to write these changes.'); + process.exit(0); +})().catch((err) => { console.error('backfill failed:', err); process.exit(1); }); diff --git a/storage.rules b/storage.rules index 5472586..8cf1a18 100644 --- a/storage.rules +++ b/storage.rules @@ -32,11 +32,21 @@ service firebase.storage { // authenticated user read ANY estate's files by guessing the path. (The app reads // via Go-API signed URLs that bypass these rules, so this is defense-in-depth — // but the rule must still be estate-scoped, not merely authenticated.) + // + // email_verified gate (mirror of firestore.rules isEstateRole()): the + // estate_users junction is written by invitation auto-match keyed off an email + // string, so an unverified account on an invited-but-unclaimed address must NOT + // be honored. The SAME junction firestore refuses to honor for an unverified + // user must not be honored by storage for direct downloads. The principal path + // (estates.principalId) is identity the owner already proved, but we require a + // verified email there too for consistency — a principal verifies at signup. function isEstateMember(estateId) { - return request.auth != null && ( - firestore.exists(/databases/(default)/documents/estate_users/$(request.auth.uid + '_' + estateId)) - || firestore.get(/databases/(default)/documents/estates/$(estateId)).data.principalId == request.auth.uid - ); + return request.auth != null + && request.auth.token.email_verified == true + && ( + firestore.exists(/databases/(default)/documents/estate_users/$(request.auth.uid + '_' + estateId)) + || firestore.get(/databases/(default)/documents/estates/$(estateId)).data.principalId == request.auth.uid + ); } // ── Estate Document Vault ── diff --git a/web/.env.production b/web/.env.production index 37c94a0..17c8765 100644 --- a/web/.env.production +++ b/web/.env.production @@ -1 +1,31 @@ VITE_API_URL=https://finalwishes-api-860699311615.us-central1.run.app + +# ───────────────────────────────────────────────────────────────────────────── +# OPTIONAL INTEGRATION ENV (owner-provisioned, build-time) +# +# These power features that are wired in code but stay inert — and HIDE their own +# entry points — until the owner provisions them. Leaving them unset is safe; the UI +# degrades gracefully rather than erroring. +# +# VITE_GOOGLE_OAUTH_CLIENT_ID — Google Photos import on the Heirlooms page. +# When unset, the "Import from Google Photos" button is hidden entirely. +# To enable (FR-905 / ADR-045): +# 1. In Google Cloud Console (project finalwishes-prod) enable the +# "Google Photos Picker API". +# 2. On the OAuth consent screen, add the scope +# .../auth/photospicker.mediaitems.readonly +# 3. Create a Web OAuth client; set Authorized JavaScript origins to +# https://finalwishes-prod.web.app and https://finalwishes.app +# 4. Set the client id below, then rebuild + redeploy the web app: +# VITE_GOOGLE_OAUTH_CLIENT_ID=.apps.googleusercontent.com +# +# Server-side integrations are configured on the API (Cloud Run env / Secret Manager), +# NOT here. For reference, they gate these UI states: +# - Document signing (directives "Sign Document" → "E-Signing Coming Soon" badge): +# set SIRSI_SIGN_API_KEY and/or SIRSI_SIGN_HMAC_SECRET (ADR-047 shared service, +# preferred), OR the dissociated fallback OPENSIGN_API_URL + OPENSIGN_API_KEY; +# also set OPENSIGN_WEBHOOK_SECRET so the status-confirmation webhook is accepted. +# - Stripe "Manage subscription" portal: one-time enable in the Stripe Dashboard +# (Settings → Billing → Customer Portal); until then the API returns a clear +# "not available yet — contact support" message instead of a 500. +# ───────────────────────────────────────────────────────────────────────────── diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 89817e7..62c9778 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -1,5 +1,6 @@ import js from "@eslint/js"; import globals from "globals"; +import jsxA11y from "eslint-plugin-jsx-a11y"; import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; @@ -14,11 +15,27 @@ export default tseslint.config( globals: globals.browser, }, plugins: { + "jsx-a11y": jsxA11y, "react-hooks": reactHooks, "react-refresh": reactRefresh, }, rules: { + // Accessibility (eslint-plugin-jsx-a11y) — INCREMENTAL ADOPTION: surface every + // a11y issue (clickable-div, missing-label, unlabeled-icon-button, media captions, + // autofocus, …) as a WARNING rather than an error. The plugin was added to guard + // against regressions, but the pre-existing codebase has a backlog of a11y debt + // that should be burned down in a dedicated sprint — not block the build/deploy in + // one shot. Downgrade the whole recommended set to "warn"; promote back to "error" + // file-by-file as each area is fixed. + ...Object.fromEntries( + Object.keys(jsxA11y.flatConfigs.recommended.rules).map((rule) => [rule, "warn"]), + ), ...reactHooks.configs.recommended.rules, + "jsx-a11y/click-events-have-key-events": "warn", + "jsx-a11y/no-static-element-interactions": "warn", + "jsx-a11y/anchor-has-content": "warn", + "jsx-a11y/control-has-associated-label": "warn", + "jsx-a11y/label-has-associated-control": "warn", // Downgrade pre-existing react-hooks issues to warnings // TODO: Fix these properly in a code quality sprint "react-hooks/rules-of-hooks": "warn", diff --git a/web/package.json b/web/package.json index 4e5d2f4..a52cf26 100644 --- a/web/package.json +++ b/web/package.json @@ -57,6 +57,7 @@ "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", "eslint": "^9.39.2", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^14.0.0", diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index 67a8931..2e0f40d 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -39,8 +39,8 @@ export class ErrorBoundary extends Component {
-

Something went wrong

-

+

Something went wrong

+

We encountered an error loading this page. This may be a temporary issue with your connection to our servers.

@@ -51,13 +51,13 @@ export class ErrorBoundary extends Component { > Try Again -

+

If this persists, try refreshing the page or contact support.

{this.state.error && ( -
- +
+ Technical Details
diff --git a/web/src/components/estate/InviteTeamMember.tsx b/web/src/components/estate/InviteTeamMember.tsx
index 4c63990..6498274 100644
--- a/web/src/components/estate/InviteTeamMember.tsx
+++ b/web/src/components/estate/InviteTeamMember.tsx
@@ -30,14 +30,13 @@ const STATUS_BADGES: Record('heir');
   const [sending, setSending] = useState(false);
@@ -81,7 +80,6 @@ export function InviteTeamMember({ estateId }: InviteTeamMemberProps) {
     const result = await inviteTeamMember({
       estateId,
       email: email.trim(),
-      phone: phone.trim() || undefined,
       fullName: fullName.trim(),
       role,
       invitedBy: user.uid,
@@ -92,7 +90,6 @@ export function InviteTeamMember({ estateId }: InviteTeamMemberProps) {
     if (result.success) {
       setSuccess(`Invitation sent to ${fullName || email}`);
       setEmail('');
-      setPhone('');
       setFullName('');
       setShowForm(false);
       refreshInvitations();
@@ -103,9 +100,12 @@ export function InviteTeamMember({ estateId }: InviteTeamMemberProps) {
   };
 
   const handleRevoke = async (invitationId: string) => {
+    setError('');
     const result = await revokeInvitation(invitationId);
     if (result.success) {
       refreshInvitations();
+    } else {
+      setError(result.error || 'Could not revoke this invitation. Please try again.');
     }
   };
 
@@ -259,17 +259,11 @@ export function InviteTeamMember({ estateId }: InviteTeamMemberProps) {
                   className="w-full bg-white border border-royal/10 rounded-xl px-4 py-3 text-[14px] font-semibold text-royal outline-none focus:border-royal transition-all placeholder:text-royal/20"
                 />
               
-              
- - setPhone(e.target.value)} - placeholder="+1 (555) 123-4567" - className="w-full bg-white border border-royal/10 rounded-xl px-4 py-3 text-[14px] font-semibold text-royal outline-none focus:border-royal transition-all placeholder:text-royal/20" - /> -
+

+ + The invitee receives a secure email invitation to join this estate. +

diff --git a/web/src/components/estate/QuorumPanel.tsx b/web/src/components/estate/QuorumPanel.tsx index 724fa8c..70b9354 100644 --- a/web/src/components/estate/QuorumPanel.tsx +++ b/web/src/components/estate/QuorumPanel.tsx @@ -80,7 +80,7 @@ export function QuorumPanel({ estateId }: QuorumPanelProps) {
- + Executor Quorum
@@ -99,14 +99,14 @@ export function QuorumPanel({ estateId }: QuorumPanelProps) { {pendingActions.length === 0 && resolvedActions.length === 0 && ( -

+

No quorum actions yet. Propose an action that requires multi-executor approval.

)} {pendingActions.length > 0 && (
-

+

Pending Votes

{pendingActions.map((action) => ( @@ -123,7 +123,7 @@ export function QuorumPanel({ estateId }: QuorumPanelProps) { {resolvedActions.length > 0 && (
-

+

Resolved

{resolvedActions.slice(0, 5).map((action) => ( @@ -184,16 +184,16 @@ function ActionCard({ } return ( -
+
- {action.description} + {action.description} {ACTION_TYPE_LABELS[action.actionType] || action.actionType}
-

+

Proposed by {action.proposedByName} ·{' '} {new Date(action.proposedAt).toLocaleDateString('en-US', { month: 'short', @@ -284,7 +284,7 @@ function ActionCard({ )} {isPending && hasVoted && ( -

You have already voted on this action

+

You have already voted on this action

)}
) @@ -328,23 +328,23 @@ function ProposeDialog({ - + Propose Quorum Action - + This action requires approval from at least 2 executors before it can proceed.
-