{"_id":"@alphea/connect","_rev":"6-4283fed6f60bdf9c8d4699a422c9c2c9","name":"@alphea/connect","dist-tags":{"rc":"0.1.4-rc.3","latest":"0.1.8"},"versions":{"0.1.4-rc.1":{"name":"@alphea/connect","version":"0.1.4-rc.1","license":"Apache-2.0","_id":"@alphea/connect@0.1.4-rc.1","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"dist":{"shasum":"189ffd7bb5e98fe55c2d97976f55f03840003e92","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.4-rc.1.tgz","fileCount":83,"integrity":"sha512-tSqFpP8QSzuZQ8UB8CXh7gyVOAjTxZLM/sfn9CbEIHPraz4+8IjA4JNY8fNKai6F5dkadwfdfAiqub6CxXTWBA==","signatures":[{"sig":"MEYCIQDrhzXBQnpVTswl+r1xyUx3zC9ur/sWYdLxOaisngZIuQIhAIHfU3QJQNmiwQWrjX+Ocvy4wtA/S8P5VuSC1eYxE7xZ","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":346434},"type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"0b01a6feb496b7eaca62d4ac6bc269d4e410debe","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/connect_0.1.4-rc.1_1786424699395_0.4051737381783622","host":"s3://npm-registry-packages-npm-production"}},"0.1.4-rc.2":{"name":"@alphea/connect","version":"0.1.4-rc.2","license":"Apache-2.0","_id":"@alphea/connect@0.1.4-rc.2","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"dist":{"shasum":"ff47cc2f9aacda5ffbc9151b82389e47e7476561","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.4-rc.2.tgz","fileCount":87,"integrity":"sha512-GtSdNY2RytAW4/mWxgGx05lwDDnQdwXTYwYLOpVyi0eSJrlz5Ageoy9hSuZ7KSMDZ1+WaXeN+nNv3YfNNiMAvw==","signatures":[{"sig":"MEMCH2V3Ybaz0oBneSP5E1Z8cryoTbzAg95mRK2n4XjCuHwCIHp+M5fBTyOEc9SHGHJsxSrtdgnPcB0IPYg7AzoAFTk4","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":358105},"type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"4e400c25939210868a191a74aaa8cb029b0bed13","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"readmeFilename":"README.md","_npmOperationalInternal":{"tmp":"tmp/connect_0.1.4-rc.2_1786503205176_0.18444528326891896","host":"s3://npm-registry-packages-npm-production"}},"0.1.4-rc.3":{"name":"@alphea/connect","version":"0.1.4-rc.3","license":"Apache-2.0","_id":"@alphea/connect@0.1.4-rc.3","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"dist":{"shasum":"155a65dd8c6139e87cda73d1eca0ddc331eca844","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.4-rc.3.tgz","fileCount":87,"integrity":"sha512-UbI2Hx3wJWk+p51KLnZg0u5nYLheTLQQxkuuF865HN4OMeAoBatMtrr8A1y83JiXkDWxTZ/06+Zojsiu0LarOg==","signatures":[{"sig":"MEUCIQDFbxPvRrjoWZ+8tn2VARY08Iw7SoZ7CWADkYwJaC23aAIgbYuObPeMwu6ycQlJBTtfNsOKeVSb3Nf/llJng8pjr3o=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":378076},"type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"c24b32cfaa5dac6519d4f26e882e69848e7d9eb3","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"readmeFilename":"README.md","_npmOperationalInternal":{"tmp":"tmp/connect_0.1.4-rc.3_1786982282210_0.8315976515031451","host":"s3://npm-registry-packages-npm-production"}},"0.1.5":{"name":"@alphea/connect","version":"0.1.5","license":"Apache-2.0","_id":"@alphea/connect@0.1.5","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"dist":{"shasum":"9b6a69711910359035129d889cb4f0e872914aae","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.5.tgz","fileCount":107,"integrity":"sha512-Uo0okUL+BVnPmIBnKva/mfnOZzczTAGVyYYCA2SrKB29o+7zwYqaMaRfuntlS3nlJSxw1fosFdmVe4vP0RIWRA==","signatures":[{"sig":"MEQCIBOL2A/e+NJ6Wv4G5PxL9NG+l9j0AlMi/hQrE9L36V+mAiBPMgJSJBu21d01pcLc5jbvLMQs0rPLTcVr/fsMlQdF1Q==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":600383},"type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"1cad142f3bcef7ab2ec70aa89c6de18360214a25","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/connect_0.1.5_1788519125133_0.6704005734338958","host":"s3://npm-registry-packages-npm-production"}},"0.1.7":{"name":"@alphea/connect","version":"0.1.7","license":"Apache-2.0","_id":"@alphea/connect@0.1.7","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"dist":{"shasum":"bfb429b04b8044d7b38fe02d9be8c5b8e2f11bbd","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.7.tgz","fileCount":111,"integrity":"sha512-4FXPd8KwTzpLmv92LNYYLmLWPCJoEqe5irCTNguxzENBmmQnf4nkq9+ZUNnEIp1Nwa6ZhHmDLXUaLt3hjn+gAg==","signatures":[{"sig":"MEUCIDAwlgIJmKEj6ZSYaJxbPl/up/a7FPHKMfa0NYPS7xHZAiEAiwpGK41uVZiYlogCSkByOU1GrKQxTIT6mholi3VVIfk=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEYCIQChf1I7t4vFOUFwY79ldiEJbmdiM1mX+Qv5j+3ghgc0NQIhALHj9+rQW3CiSBrvVraV7eXzqH3+/N21a5EU4WOgiDgj","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":688759},"type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"b20e36c755d36d6a29223757edeba0eeffecf809","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"tmp":"tmp/connect_0.1.7_1789663795296_0.21954176126009828","host":"s3://npm-registry-packages-npm-production"}},"0.1.8":{"_id":"@alphea/connect@0.1.8","dist":{"shasum":"41db5a782332b88ca3c5c0b4eaf8121fbd3cd99f","tarball":"https://registry.npmjs.org/@alphea/connect/-/connect-0.1.8.tgz","fileCount":115,"integrity":"sha512-Pcvy9b2UEuoBQLPlu2lbzBKE2vLQJQnm4S42ACKVpv8xeQVc/0Y7trVHYOapnMpWVjkAGi4LYFz7mzAMGL9Mug==","signatures":[{"sig":"MEUCIDgCaHdLGSWmLC6NI0N2iKH4W9vcEflk5NIF+Qd8mUuSAiEA/tDZb+UeeE786cmse5PLwguJOlrmWgviPQaQ64B3UC4=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIA4fz4pvzgjbsodRqWQAW0OvVUcQgQxgXU92bgLIb9eFAiEAnLnMsmewwLgRILGDyn/2d2dhcFKJ3KHzC2zRt651CPI="}],"unpackedSize":730240},"name":"@alphea/connect","type":"module","types":"./dist/connect/index.d.ts","engines":{"node":">=22.18.0"},"exports":{".":{"types":"./dist/connect/index.d.ts","default":"./dist/connect/index.js"}},"gitHead":"c0f8f96366ae2f7213fd31b66a614afc3cabe409","license":"Apache-2.0","version":"0.1.8","_npmUser":{"name":"alphea_henrypark","email":"henrypark@alphea.io"},"repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"_npmVersion":"11.13.0","description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","directories":{},"maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"sideEffects":false,"_nodeVersion":"24.17.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/connect_0.1.8_1789667154679_0.4276558520104985"}}},"time":{"created":"2026-08-11T05:04:59.229Z","modified":"2026-09-17T17:45:55.009Z","0.1.4-rc.1":"2026-08-11T05:04:59.532Z","0.1.4-rc.2":"2026-08-12T02:53:25.331Z","0.1.4-rc.3":"2026-08-17T15:58:02.381Z","0.1.5":"2026-09-04T10:52:05.273Z","0.1.7":"2026-09-17T16:49:55.380Z","0.1.8":"2026-09-17T17:45:54.770Z"},"license":"Apache-2.0","repository":{"url":"https://git.alphea.io/Alphea-Dev/alphea-framework.git","type":"git","directory":"packages/connect"},"description":"User-credentialed ALPHEA Connect RPC SDK for browser product surfaces","maintainers":[{"name":"alphea_henrypark","email":"henrypark@alphea.io"}],"readme":"# @alphea/connect\n\nUser-credentialed ALPHEA Connect RPC SDK for browser product surfaces.\n\nThis package is the fourth ALPHEA authority model. It is for app code acting as\na signed-in **end user** against public `alphea.connect.v1` services — not for\noperator tooling (`@alphea/foundation`), not for a served app runtime's ambient\ncapability session (`@alphea/client`), and not for host-mediated function grants\n(`@alphea/fn`).\n\n| Package | Authority | Boundary | Credential |\n|---|---|---|---|\n| `@alphea/client` | `browser_capability_session` | `browser_capability_gateway` | ambient runtime session |\n| `@alphea/fn` | `function_host_grant` | `function_host_bridge` | host-mediated grant |\n| `@alphea/foundation` | `foundation_bearer_session` | `foundation_rpc` | operator bearer |\n| `@alphea/connect` | `connect_user_session` | `connect_rpc` | end-user access credential |\n\n## Credential posture\n\nThe transport takes a **provider**, not a credential, and reads it fresh on\nevery call:\n\n```ts\nimport {\n  createAlpheaConnectClient,\n  createAlpheaConnectFetchTransport,\n} from \"@alphea/connect\";\n\nconst connect = createAlpheaConnectClient({\n  transport: createAlpheaConnectFetchTransport({\n    baseUrl: \"https://connect.example.com\",\n    getAccessToken: () => sessionStore.currentAccessToken(),\n  }),\n});\n```\n\nThree rules hold for every request:\n\n- **No cookies, no credentialed CORS.** Requests are issued with\n  `credentials: \"omit\"`. The `Authorization` header is the only credential, so a\n  cross-origin page that does not already hold the user's credential cannot\n  spend their session.\n- **Nothing is stored.** The provider result is used for one request and\n  discarded. The credential is never an own-property of the transport or the\n  client, so it cannot be recovered by walking the object graph.\n- **No server text is surfaced.** Errors normalize to a stable\n  `AlpheaCodedError` whose message is derived only from the call label and the\n  resolved code. Branch on `code`; correlate with the trace id.\n\n`baseUrl` must be HTTPS except for loopback development endpoints, and only\n`alphea.connect.v1` services may be targeted.\n\n## Auth and session\n\nGoogle sign-in uses browser-owned PKCE on the Hub origin. The supported\ncallbacks are `https://hub.alphea.dev/auth/google/callback` and\n`https://hub.alphea.ai/auth/google/callback`; pass `allowedCallbacks` to add a\nloopback endpoint for local development.\n\n```ts\nimport {\n  createAlpheaConnectAuthClient,\n  createAlpheaConnectMemorySessionStore,\n} from \"@alphea/connect\";\n\nconst sessionStore = createAlpheaConnectMemorySessionStore();\nconst auth = createAlpheaConnectAuthClient({ transport, sessionStore });\n\n// 1. Before redirecting. The verifier is stored, not returned.\nconst start = await auth.beginGoogleLogin({\n  redirectUri: \"https://hub.alphea.ai/auth/google/callback\",\n  clientId,\n  authorizationEndpoint,\n});\nlocation.assign(start.authorizationUrl!);\n\n// 2. On the callback page.\nconst snapshot = await auth.completeGoogleLogin(location.href);\n// { authority: \"connect_user_session\", authenticated: true, renewable: true, userId }\n```\n\n`completeGoogleLogin` validates the callback origin and path against the\nallowlist before reading anything from the URL, then consumes the pending\ntransaction — deleting it whether the login succeeds or fails, so a replayed\ncallback cannot re-drive the flow. The `redirect_uri` presented for redemption\nis the one recorded when the flow started, never a value read back out of the\ncallback.\n\nEmail sign-in uses the challenge/verify pair, with OTP or magic-link delivery:\n\n```ts\nconst { challengeId } = await auth.requestEmailChallenge({ email });\nawait auth.verifyEmailChallenge({ email, code, challengeId });\n```\n\n`refreshSession()`, `currentSession()`, and `logout()` complete the lifecycle.\n`logout()` always clears the local session, including when the server call\nfails.\n\n**No auth method returns a token.** A successful login writes the session into\nthe store and returns a redacted snapshot; requests get their credential by\nwiring the store into the transport:\n\n```ts\nimport { createAlpheaConnectAccessTokenProvider } from \"@alphea/connect\";\n\ncreateAlpheaConnectFetchTransport({\n  baseUrl,\n  getAccessToken: createAlpheaConnectAccessTokenProvider(sessionStore),\n});\n```\n\nThe default session store is in-memory, so a reload signs the user out. Supply\nyour own store to choose a persistence policy explicitly.\n\n## Staying signed in: the session coordinator\n\nThe access credential lasts an hour; the renewal credential lasts far longer.\nLeft uncomposed, those two facts produce a bad hour boundary: the token provider\ncorrectly withholds an expired access credential, the transport correctly has no\nretry policy, and the request goes out unauthenticated — so an app that reads\n`unauthenticated` as \"signed out\" sends someone back through email\nauthentication while their renewal credential is still perfectly valid.\n\n`createAlpheaConnectSessionCoordinator` is the supported way to close that gap.\nIt owns nothing: it reads the store you already have, calls the\n`refreshSession()` you already have, and hands the target call to the raw\ntransport you already have.\n\n```ts\nimport {\n  createAlpheaConnectAuthClient,\n  createAlpheaConnectDataClient,\n  createAlpheaConnectFetchTransport,\n  createAlpheaConnectAccessTokenProvider,\n  createAlpheaConnectSessionCoordinator,\n} from \"@alphea/connect\";\n\n// One store, shared by everything below.\nconst sessionStore = createPersistentSessionStore();\n\nconst transport = createAlpheaConnectFetchTransport({\n  baseUrl,\n  getAccessToken: createAlpheaConnectAccessTokenProvider(sessionStore),\n});\nconst auth = createAlpheaConnectAuthClient({ transport, sessionStore });\n\nconst coordinator = createAlpheaConnectSessionCoordinator({\n  transport,        // the RAW transport, the same one auth uses\n  auth,\n  sessionStore,\n  // Optional: your durable store's own fence, so a refresh response that\n  // arrives after a logout or an account switch cannot overwrite the winner.\n  withSessionRotation: sessionStore.withSessionRotation,\n});\n\n// Authenticated clients get the coordinated transport.\nconst data = createAlpheaConnectDataClient({ transport: coordinator.transport });\n```\n\nBuild the auth client on the **raw** transport, never on\n`coordinator.transport`. `RefreshSession` and `CurrentSession` must not travel\nthrough a wrapper that would try to renew before sending them.\n\n### Renewal happens before dispatch, or not at all\n\n`coordinator.transport` renews only when the stored access credential is\n*already known to be expired* — the exact condition under which\n`createAlpheaConnectAccessTokenProvider` would withhold it. It renews, then\ndispatches the target call **once**, letting the raw transport read the\nreplacement credential out of the store for itself. Reads and mutations are\ntreated identically, because nothing has been sent yet.\n\n**Nothing is ever replayed after dispatch.** A 401, a timeout, a rejected\n`fetch`, an `unavailable` response, or any outcome that cannot be classified is\nreturned to you exactly as it came back. A client that cannot prove a request\ndid not execute must not send it a second time, and the public contract carries\nno such proof — so a withdrawal that timed out is reported, never resent. That\nis deliberately narrower than a \"retry the idempotent methods\" policy: no replay\nis needed to fix known pre-dispatch expiry.\n\nAll coordinator callers share one renewal flight, so N simultaneous requests\nwith an expired credential perform one `RefreshSession` and N target calls.\nNothing is cached between calls — every decision starts by reading the store\nagain.\n\n### `ensureReady()` for mount and resume\n\n```ts\nconst readiness = await coordinator.ensureReady();\n\nswitch (readiness.status) {\n  case \"ready\":\n    // readiness.snapshot is the redacted session projection — no token.\n    return mountGuardedContent();\n  case \"signed_out\":\n    // \"missing_session\" — nothing stored. \"renewal_refused\" — the server\n    // refused to renew the credential that is still stored.\n    return sendToSignIn();\n  case \"retryable\":\n    // readiness.code, and readiness.requestId when the server sent a trace id.\n    return showRecoverableError();\n}\n```\n\nCall it on cold mount and on your deduplicated focus/visibility/online resume\nedge. Concurrent calls share the same work: three resume events produce one\n`CurrentSession` check and at most one renewal.\n\n`retryable` is the state that earns its place. `unavailable`,\n`deadline_exceeded`, `resource_exhausted`, a malformed success, a storage\nfailure, and any code whose meaning is unproven all land there — and they leave\nthe stored session exactly as it was. Only two things produce `signed_out`: an\nempty store, and a `RefreshSession` refusal for a credential that is *still the\none in the store*. A network blip never gets to claim that a session ended.\n\nIf the credential pair changed while a refresh was in flight — another tab\nrotated first, or the account was replaced — the coordinator re-reads the store,\nvalidates that winner with a raw `CurrentSession`, and defers to it. The losing\nresponse is never written over the winner.\n\n### What it does not do\n\n- **No client session deadline.** Core is authoritative for refresh expiry,\n  rotation, and revocation. This package has no absolute browser window and no\n  TTL of its own; how long a session persists in a browser is your store's\n  policy and the server's answer.\n- **No background refresh.** There is no timer, no interval, and no scheduler.\n  Renewal happens on a request that needs it, or on `ensureReady()`.\n- **No credential.** `ensureReady()` returns a redacted snapshot; the raw\n  transport, auth client, and store are held in closures and are not properties\n  of the returned coordinator. Nothing here returns token material.\n- **No storage policy.** `withSessionRotation` is yours. Omit it and rotations\n  execute directly, which is right for an in-memory store and wrong for a\n  durable one shared across tabs.\n\n## Caller profile\n\n```ts\nconst data = createAlpheaConnectDataClient({ transport });\n\nconst profile = await data.profile.get();\n// { email, displayName, avatarUrl, avatarKind, isPremium, serverTime }\n```\n\nThis is the **only** supported source of a user-facing display name. Do not\nderive one from the session's email, the account or user id, or Google/token\nclaims: those are authentication material, not a name the user chose to show.\n\n`displayName` is **server-owned and never empty on a read**. When the caller has\nset no name, the server substitutes a stable non-PII fallback of the form\n`Alphea User 3F2A91`. Render what arrives:\n\n```ts\nelement.textContent = profile.displayName;\n```\n\nDo **not** write `profile.displayName || \"Alphea User\"`. The server has already\nmade that decision, and it made the same one for every client — a local fallback\nwould show this user a different label than the mobile app shows for the same\naccount. (The proto's \"empty = unset\" note on `display_name` is about the stored\nvalue: sending an empty name to `UpdateProfile` clears it, and the next read\nresolves it to the fallback.)\n\n`avatarUrl` is the genuinely optional field: it is an empty string when the\ncaller has no avatar, which the contract defines as \"use the client default\", so\nchoosing that placeholder is the app's call.\n\n`isPremium` is derived server-side from an active subscription and is never\nclient-set. `avatarKind` is a union including `AVATAR_KIND_UNSPECIFIED`, so an\navatar kind this build does not know about stays visibly unknown.\n\nOnly the profile read and the store pair below are wrapped. Display-name and\nphoto mutations, the subscription read and receipt pair, and account\ndeactivation exist on the same Core service but are not part of this package's\nsurface.\n\n## Store purchases and entitlements\n\n```ts\nconst data = createAlpheaConnectDataClient({ transport });\n\nconst purchase = await data.profile.submitStorePurchase({\n  platform: \"STORE_PLATFORM_PLAY_STORE\",\n  productId: \"premium_monthly\",\n  purchaseToken,          // the proof your app already holds\n  idempotencyKey: \"a-client-minted-key\",\n});\n// { productId, state, entitlementIds, serverTime }\n\nconst { entitlements, serverTime } = await data.profile.listEntitlements();\n// entitlements: [{ id, source, grantedAt }, ...]\n```\n\nBoth live under `data.profile` because the canonical Core service really is\n`ProfileService`. There is no store client, no billing abstraction, and no raw\nRPC escape hatch: a second client would be a second authority boundary for the\nsame credential.\n\n**This SDK does not buy anything.** The purchase happens in the platform store,\noutside this package. What you pass in is a proof you already hold, and the only\nthing the SDK does with it is hand it to Core once on your existing bearer\nsession. Core alone verifies the token with the platform, binds it to the\nauthenticated account, and installs or revokes entitlement state. Nothing here\nacknowledges a token with the provider, grants, restores, retries, caches, or\npolls.\n\n### A resolved promise is not a grant\n\n`state` is the answer, and the four decided outcomes are four different facts:\n\n| `state` | what it means |\n| --- | --- |\n| `STORE_PURCHASE_STATE_PENDING` | Core took the proof and has not finished deciding. Nothing is granted. |\n| `STORE_PURCHASE_STATE_GRANTED` | Core verified the purchase and installed entitlement state. |\n| `STORE_PURCHASE_STATE_REVOKED` | An entitlement that existed has been withdrawn — a refund or chargeback. |\n| `STORE_PURCHASE_STATE_REJECTED` | Core declined the proof. Nothing was ever granted. |\n| `STORE_PURCHASE_STATE_UNSPECIFIED` | Core answered without naming a state. Not a success. |\n\nBranch on `state`, never on the promise resolving:\n\n```ts\nif (purchase.state === \"STORE_PURCHASE_STATE_GRANTED\") {\n  // ...unlock\n}\n```\n\n`entitlementIds` names the entitlements the purchase is **attached to**, and a\nnon-empty list is not a grant — a `REVOKED` purchase can still name the\nentitlement it used to carry. The decoders never promote one state to another,\nnever derive a state from elapsed time, and never conclude `GRANTED` because\nthat list came back populated.\n\nThere is no poller. To find out what happened after a `PENDING` submission, call\n`listEntitlements()` again; this package remembers nothing between calls, so an\nempty list means **Core says you hold nothing**, never that a local cache has not\nwarmed up.\n\n### The purchase token is credential material\n\n`purchaseToken` is the one field the contract marks `debug_redact=true`. It goes\nout in exactly one request body, it appears in no result this package returns\n(even if a server echoes it), and no local refusal message quotes it.\n`redactConnectPayload` strips it under `purchaseToken`, `purchase_token`,\n`PURCHASE-TOKEN` and any equivalent spelling:\n\n```ts\nlogger.info(\"submit\", redactConnectPayload(input));\n// { platform: \"...\", productId: \"...\", purchaseToken: \"[redacted]\", idempotencyKey: \"...\" }\n```\n\nDo not log the raw input object.\n\n### What is refused locally, and what is not\n\nThe submission is validated **structurally** before anything is dispatched, and\na failure is the package's existing `invalid_argument` error:\n\n- `platform` must be `STORE_PLATFORM_APP_STORE` or `STORE_PLATFORM_PLAY_STORE`.\n  `STORE_PLATFORM_UNSPECIFIED` is refused for the same reason an empty\n  `productId` is — it is the absence of an answer on a field the request needs\n  one for.\n- `productId` must be a non-empty string.\n- `purchaseToken` must be 1–4096 characters.\n- `idempotencyKey` must be 1–128 characters, client-minted and caller-scoped.\n\nEverything else is Core's. Whether a product exists, whether a platform is\nserved, whether a token is genuine and whether it has already been redeemed are\nserver decisions, and pre-empting any of them here would mean shipping a billing\ncatalog inside an SDK that goes stale the first time the catalog moves.\n\n**Current server-side limitation.** At the pinned contract the deployed server\naccepts **Play purchases only**, and answers a request it cannot serve — an\nunsupported platform or product, or a provider that is not configured in that\nenvironment — with its ordinary stable coded error, typically `unavailable` or\n`invalid_argument`. That is a deployment fact rather than a contract fact, which\nis why `STORE_PLATFORMS` still names both stores and why the SDK does not refuse\n`STORE_PLATFORM_APP_STORE` on your behalf. The SDK also does not catch an\nunavailable provider and retry: resending the same purchase token on the\nstrength of an SDK's guess is not a recovery, and the error reaches you with its\ncode, `requestId` and bounded retry-after metadata intact.\n\n### Decoding fails closed\n\n`decodeStorePurchase` and `decodeEntitlements` are exported for callers holding a\nresponse from elsewhere, and both are strict where the rest of the package is\ntotal. An **absent** field resolves to its proto3 default, because that is what\nthe wire encoding means. A field that is **present but unreadable** — an unknown\nenum name, an out-of-range ordinal, a string field holding a number, a body\ncarrying contradictory `camelCase` and `snake_case` values for one field — is\nrefused with `internal` rather than folded into a plausible-looking default.\nReading \"Core said something this build cannot recognize\" as `UNSPECIFIED` would\nfabricate an answer exactly when the server has gone wrong and a paying user most\nneeds the truth. Entitlement order is the server's and is never re-sorted.\n\n## Points, referral, rounds, and redeem\n\n```ts\nimport { createAlpheaConnectDataClient, isRedeemAccepted } from \"@alphea/connect\";\n\nconst data = createAlpheaConnectDataClient({ transport });\n\nawait data.points.balance();\nawait data.points.history({ yearMonth: { year: 2026, month: 8 }, page: { pageSize: 25 } });\nawait data.referral.friends({ query: \"al\" });\nawait data.rounds.list({ pageSize: 10 });\nawait data.redeem.status();\n```\n\nPoint amounts carry both `micros` (convenient) and `microsText` (the exact wire\nvalue), because a 64-bit micros figure can exceed `Number.MAX_SAFE_INTEGER` and\na balance is the wrong place to lose a digit.\n\n### Round display names\n\nEvery round read — `rounds.status()`, `rounds.list()` and `rounds.get()` —\ncarries `displayName`, the round's operator-given name, exactly as the server\nstored it:\n\n```ts\n// The open round, as a live surface would read it.\nconst round = await data.rounds.status();\n\n// Empty means the round has no name. Choose your own label; the SDK will not.\nconst label = round.displayName || `Round ${round.roundNumber}`;\n```\n\nThree properties are worth relying on:\n\n- **It is presentation only.** `roundNumber` remains the allocation, ordering\n  and distribution identity. A display name never affects eligibility,\n  lifecycle, Merkle data, a claim proof, or settlement.\n- **Empty is a state, not a missing read.** A round whose name was cleared and a\n  round that never had one both decode to `\"\"`. Write the fallback in your own\n  surface, as above — the SDK does not synthesise `Round 21`, because a name it\n  invented would be indistinguishable from one an operator typed.\n- **It is carried verbatim.** No trimming, casing, or truncation happens here.\n  Numeric round allocation may legitimately skip numbers, so the name and the\n  number answer different questions and neither substitutes for the other.\n\nThe field is declared once, on the shared round view, so the status call and the\ninventory cannot drift into two different readings of the same name.\n\n### Redeem outcomes are in the body, not the status code\n\nThe redeem mutations report business outcomes in a response enum, not as\ntransport errors. A cap-exceeded redeem returns 200. **A resolved promise is\nnot success:**\n\n```ts\nconst result = await data.redeem.create({\n  roundId,             // required; see \"Submitting a redeem\" below\n  idempotencyKey,      // client-minted, 1-128 chars\n  walletAddress,       // required; the server never auto-selects a wallet\n  amountMicros,        // > 0\n});\n\nif (!result.accepted) {\n  // result.outcome is e.g. REDEEM_OUTCOME_CAP_EXCEEDED\n}\n```\n\n`result.accepted` and `isRedeemAccepted(outcome)` are the same check, spelled\ntwo ways so the wrong one is hard to write.\n\n### Submitting a redeem\n\nThe redeem **reads** resolve a round when you omit one: the open round if there\nis one, otherwise the most recent. The two **mutations** — `create` and `repin`\n— do not: they require an explicit `roundId` and the server rejects a request\nwithout one. So read first, then submit, and submit only when the round the\nserver returned is actually `ROUND_STATUS_OPEN`:\n\n```ts\nconst status = await data.redeem.status();          // no argument: open, else latest\n\n// The returned round may be a closed past round, so gate on the status you got\n// back rather than on the fact that a round came back at all.\nif (status.status === \"ROUND_STATUS_OPEN\" && status.canRedeem) {\n  // The server never auto-selects a wallet, and a first-time redeemer has no\n  // pinned one yet, so choose an ACTIVE linked wallet explicitly.\n  const wallets = await data.wallet.list();\n  const wallet = wallets.find((candidate) => candidate.status === \"ACTIVE\");\n  if (!wallet) {\n    // Nothing to redeem into yet — bind one with data.wallet.bind(...).\n    return;\n  }\n\n  const result = await data.redeem.create({\n    roundId: status.roundId,                        // pass it back verbatim\n    idempotencyKey,\n    walletAddress: wallet.walletAddress,\n    amountMicros,\n  });\n}\n```\n\nRepinning moves an accepted participation's payout to a **different** active\nwallet, so select one that is not the current pin:\n\n```ts\nif (status.status === \"ROUND_STATUS_OPEN\" && status.canRepin) {\n  const wallets = await data.wallet.list();\n  const next = wallets.find(\n    (candidate) =>\n      candidate.status === \"ACTIVE\" &&\n      candidate.walletAddress.toLowerCase() !== status.pinnedWalletAddress.toLowerCase(),\n  );\n  if (next) {\n    await data.redeem.repin({ roundId: status.roundId, walletAddress: next.walletAddress, idempotencyKey });\n  }\n}\n```\n\n### Withdrawing from a round\n\n`redeem.withdraw` removes the **caller's own** participation from a round,\nentirely, and refunds it.\n\n```ts\nconst result = await data.redeem.withdraw({ roundId: status.roundId, idempotencyKey });\n\nif (result.accepted) {\n  showRefund(result.refunded);          // what was ACTUALLY reversed\n} else {\n  // REDEEM_OUTCOME_NOT_PARTICIPATING — there was nothing to withdraw\n  // REDEEM_OUTCOME_ROUND_CLOSED       — the round can no longer be left\n}\n```\n\nThe request names a round and an idempotency key and **nothing else**. There is\ndeliberately no redeem identifier: one the caller supplies is one they could\nchange, which would make this a way to ask the server to reverse somebody\nelse's participation. What gets reversed is found from the authenticated caller.\n\nIt is **all or nothing**. There is no partial amount, because a partial\nreduction has to reason about the round's aggregate floor and can leave a\nparticipant holding a total the round was configured to exclude; withdrawing\nentirely cannot, since nothing remains to be under the floor.\n\n`result.refunded` is the amount actually reversed, and it is **not necessarily\nthe participation you last read** — a redeem an operator cancelled in between is\nnot refunded twice. Render this figure rather than one computed locally. As\neverywhere else on this surface, a resolved promise is not success: read\n`accepted`, and an outcome this build does not recognize is never a completed\nwithdrawal.\n\n`canRedeem` and `canRepin` are server-computed, so gating on them keeps round\nlifecycle logic out of your app. The SDK never picks a round or a wallet for\nyou: omitting `roundId` is a typed `invalid_argument` rather than a guess, and\n`status.pinnedWalletAddress` is an empty string until a pin exists — it is the\ncurrent pin to compare against, not a wallet to submit with.\n\nAuthentication is the transport's job, not an argument here. Compose the client\nover a session-backed token provider (see **Auth and session**) and the caller's\ncredential travels as the `Authorization` header only — never in the request\nbody, the result, or an error.\n\nRetries reuse **your** idempotency key: the SDK passes it through unchanged and\nnever mints, rotates, or de-duplicates locally. Replay semantics belong to the\nserver, which keys them on the caller and that key.\n\n### Contract pinning\n\n`ALPHEA_CONNECT_SURFACE` mirrors the Connect v1 operations this package\ntargets, pinned to the Core commit in `ALPHEA_CONNECT_CONTRACT_REF`. Set\n`ALPHEA_CORE_CONNECT_SURFACE` to a Core surface fixture to cross-check the\nmirror against it.\n\nThe commit ref names *which* contract; `ALPHEA_CONNECT_CONTRACT_FIXTURE_SHA256`\nrecords the digest of that contract's bytes. The accepted surface is vendored in\nthe repository as a test asset and its digest is verified on **every** run, with\nno opt-in and no skip path: a conformance check that only runs when someone\nremembers to set a variable is a gate-shaped thing that is off by default, and a\nskipped conformance test reads exactly like a passing one. `ALPHEA_CORE_CONNECT_\nSURFACE` survives only as an operator override for cross-checking against a live\nCore checkout, and it must satisfy the same digest — so it can fail a run, never\nskip or redirect one. The vendored fixture lives under `src/`, which the publish\nboundary forbids, so it never reaches the package.\n\n`data.nonLiveOperations` lists mirrored operations that are not live in the\npinned Core contract. It is empty for this surface. A live staging HTTP run is a\nseparate verification receipt for the deployment, not a reason for the SDK to\nmark Core-confirmed operations unavailable.\n\n## Wallet binding\n\nA bound wallet is a **claim destination, not a login method** — binding never\ncreates or changes a session.\n\nThe SDK never touches a wallet provider. You supply a two-method adapter, so\nyour app keeps ownership of `window.ethereum`, wagmi, WalletConnect, or\nwhatever it uses:\n\n```ts\nimport { createAlpheaConnectDataClient, type AlpheaConnectSigner } from \"@alphea/connect\";\n\nconst signer: AlpheaConnectSigner = {\n  getAddress: () => selectedAccount,\n  personalSign: ({ address, message }) =>\n    provider.request({ method: \"personal_sign\", params: [message, address] }),\n};\n\nconst binding = await data.wallet.bind(\n  { walletAddress, chainId, expectedDomain: \"hub.alphea.ai\" },\n  signer,\n);\n```\n\n`bind` runs the whole flow: check the selected account, request a\nserver-composed challenge, check it is safe to sign, **re-check the selected\naccount**, sign, verify. The account is checked twice because a wallet UI lets\nsomeone switch accounts at any moment, including while a consent dialog is\nopen — the only check that means anything is the one taken next to the\nsignature.\n\nThe message signed is the server's `payload`, verbatim. This package never\nbuilds or edits a signing payload; a client that composes its own is a client\nthat can be talked into signing the wrong thing.\n\n`expectedDomain` is optional but worth passing. It is checked **before** the\nsigning dialog appears, so a payload that did not come from the environment you\nexpected stops the flow rather than becoming a signature someone approved\nwithout reading.\n\nThe individual steps are available as `wallet.challenge()` and\n`wallet.verify()`, alongside `wallet.list()` and `wallet.unlink(address)`.\n\nSignatures are credential-shaped: they are in the redaction denylist, and no\nerror raised by this package carries a signature, a payload, or a nonce.\n\n## Reward claim\n\nClaiming is the only thing this package does that can move value on a chain, so\nit is built to make the dangerous step hard rather than convenient. The SDK\nnever decides *whether* someone may claim — eligibility, allocation, and Merkle\nauthority are the server's. It decides whether what the server said is\ninternally consistent, whether the proof actually proves it, and whether the\nwallet is where it is supposed to be.\n\n```ts\nconst targets = await data.claim.targets();          // signed discovery\nconst allocations = await data.claim.list();\nconst allocation = allocations.rewards[0];\n\nconst target = selectClaimTarget(targets, {\n  chainId: allocation.chainId,\n  distributor: allocation.distributor,\n});\n\nconst proof = await data.claim.proof({\n  chainId: allocation.chainId,\n  distributor: allocation.distributor,\n  distributionId: allocation.distributionId,\n});\n\nconst submitted = await data.claim.send(\n  { proof, target, allocation, serverTime: allocations.serverTime },\n  chainSigner,\n);\n```\n\n`allocation` and `serverTime` are **required**. The allocation is the second\nindependent read the proof is checked against — the server's `claimable` and\n`claimed` gates, the amount, the payout wallet, and the claim window — and\n`serverTime`, from that same `claim.list()` response, is the only clock allowed\nto judge whether the window is still open.\n\n### The proof is recomputed locally, before the wallet is touched\n\nThe server returns a leaf, an ordered proof, and a root. Trusting all three\nwould leave the server's correctness as the only thing between a wrong\nallocation and a signed transaction — and a Merkle proof is exactly the artifact\nthat removes that dependency. So the SDK rebuilds the leaf from the fields it is\nabout to encode into calldata and folds the proof up to the published root:\n\n```ts\nimport { hashClaimLeaf, verifyClaimMerkleProof } from \"@alphea/connect\";\n```\n\n`hashClaimLeaf` reproduces the distributor's own `hashLeaf`: the six-word\n`abi.encode` of `(chain_id, distributor, distribution_id, leaf_index,\nwallet_address, amount_base_units)`, hashed **twice**. Nodes combine as\n`keccak256` of the pair sorted ascending as big-endian `uint256`.\n\nTwo things are easy to conflate and both matter: the proof **elements** are\nordered leaf-to-root and reordering them is fatal, while each **pair** is sorted\nbefore hashing — which is what lets the contract verify without direction bits.\nAn empty proof is valid: in a single-leaf distribution the leaf *is* the root.\n\nA tampered amount, a swapped payout wallet, a proof borrowed from another\ndistribution, or a root from a different tree all fail here, with no wallet\nprompt and no gas spent. The chain and distributor hashed into the leaf are\ntaken from the **signed discovery target**, not from the leaf's own copies of\nthem, so the hash is anchored to signed data.\n\n### The ABI guard\n\n`buildClaimCalldata` refuses a distributor whose `distributorAbiFingerprint` is\nnot one this SDK can encode for. Both forms operators configure are accepted:\nthe `merkle-distributor-v1` codec label, and the canonical ABI-fingerprint hash\nin `ALPHEA_CONNECT_DISTRIBUTOR_V2_ABI_FINGERPRINT` (compared case-insensitively,\nsince a keccak digest is the same value in either case). The claim selector is\nboth **derived and pinned** — the derivation catches a changed signature, the\npin catches a broken hash.\n\n### One send site, and the chain checked twice\n\nThere is exactly one place in the package that calls `sendTransaction`, and a\nstructural test fails the build if a second one ever appears. Around it:\n\n- the chain is checked **before the estimate and again immediately before the\n  send**, because a wallet lets someone switch networks while a confirmation\n  dialog is open, and the earlier check has expired by then;\n- the gas payer is read explicitly and surfaced, and is deliberately **not**\n  required to equal the payout wallet. The distributor is permissionless: paying\n  gas on someone else's behalf is supported, and the payout goes to the wallet in\n  the leaf regardless of who sent the transaction. Adding an equality check would\n  look like a safety improvement and would break a working case;\n- a failed estimate stops the flow rather than letting the user pay for a\n  revert.\n\nThe signer is passed per call, so the client never retains a route to a wallet\nprovider.\n\n### A declined dialog is not a failure\n\n```ts\ntry {\n  await data.claim.send(\n    { proof, target, allocation, serverTime: allocations.serverTime },\n    chainSigner,\n  );\n} catch (error) {\n  if (isUserRejectedRequest(error)) {\n    // The person said no. Offer the action again; do not show an error.\n  }\n}\n```\n\n`isUserRejectedRequest` is safe on anything a `catch` produced, and it has to\nbe: the two errors a caller can plausibly hold are different shapes. A claim\nsent through this package throws a coded error whose provider cause was\ndeliberately discarded, leaving only a narrow state; a caller that reached its\nprovider directly still holds the raw EIP-1193 error. Both are recognized.\n\nUnderneath, rejection is identified by **error code** only — EIP-1193 `4001`, or\nethers' `ACTION_REJECTED`, including one level of nesting — never by matching\nmessage text, which is localized and is exactly where provider detail would\nleak. The provider's own message never travels into the thrown error.\n\n### A transaction hash is not a claim\n\n`claim.send` resolving means the wallet accepted a transaction. It does not mean\nthe claim happened, and neither does any HTTP 200. The transaction may still be\nreplaced, time out, revert, or land in a block that is later reorganized away.\nReconcile it against a chain reader you supply:\n\n```ts\nimport { reconcileClaimTransaction } from \"@alphea/connect\";\n\nconst observation = await reconcileClaimTransaction(\n  {\n    transactionHash: submitted.transactionHash,\n    chainId: submitted.chainId,\n    distributor: submitted.distributor,\n    distributionId: submitted.distributionId,\n    leafIndex: submitted.leafIndex,\n    payoutWallet: submitted.payoutWallet,\n  },\n  chainReader,        // getTransactionReceipt, getBlockNumber, getChainId\n);\n\nif (observation.claimConfirmed) {\n  // and only then\n}\n```\n\n| State | What it means for a UI |\n|---|---|\n| `user_rejected` | The person declined. Not an error; offer it again. |\n| `submitted` | Accepted, not yet settled. Wait. |\n| `replaced` | The node no longer knows this hash — sped up, cancelled, or dropped. May still land under a different hash, so **not** a failure. |\n| `timed_out` | No answer within the budget. Says nothing about the outcome. |\n| `reverted` | Mined and rejected by the contract. Gas spent, nothing moved. |\n| `reorged` | A receipt was seen and then unseen or moved. Withdraw anything reported from it. |\n| `confirmed` | Mined, deep enough, and carrying the distributor's own `Claimed` event for this distribution, leaf, and wallet. |\n| `unknown` | Nothing observable. Never a substitute for the others. |\n\n`claimConfirmed` is true for `confirmed` and nothing else. Three checks stand\nbetween a receipt and that word, and each exists because skipping it produces a\nconfident wrong answer:\n\n- **The reader must be on the claim's chain.** `getChainId` is read and compared\n  before the first poll, and a mismatch or an unreadable chain refuses outright.\n  A reader aimed elsewhere answers just as confidently about a chain the claim\n  was never sent to.\n- **The receipt must be this claim's receipt.** Its `transactionHash` must be\n  present, canonical, and equal to the one being observed — compared\n  case-insensitively, since providers write hashes both ways. Without that, no\n  verdict is reached at all, not even `reverted`: telling someone their claim\n  failed on the strength of an unrelated transaction is a wrong answer, not a\n  safe one. A canonical `blockHash` is required for the same reason, since a\n  verdict that cannot be re-checked on a later poll cannot be withdrawn when it\n  stops holding.\n- **The `Claimed` event must be there.** A successful status is not enough: a\n  transaction can succeed while doing something else entirely, and reporting\n  that as a claim is the exact mistake this step exists to prevent.\n\n`getTransaction` on the reader is optional and only sharpens the answer —\nwithout it a missing receipt cannot be told apart from a pending one, so\nreconciliation keeps waiting instead of guessing `replaced`.\n\n`classifyClaimReceipt` is exported for a caller that already holds a receipt and\napplies the same gate, including on the head and confirmation-depth arguments it\nis handed directly.\n\n### The claim window\n\n`claimExpiresAt` is on both the allocation and the proof: an RFC3339 instant\nsaying when the on-chain claim window closes. It is **server-derived** from the\nverified publication observed for that distribution, and the SDK treats it as\nauthoritative rather than computing anything of its own.\n\n**An unbound window has no send path.** An empty `claimExpiresAt` means \"ask\nagain\", never an open-ended window, so a send that cannot establish when the\nwindow closes is refused rather than attempted — the alternative is telling\nsomeone their claim is live on the strength of a blank field. The read surfaces\nstill report an empty value losslessly; it is the *send* that requires a bound,\nagreed, server-assessed window.\n\n- **Both reads must agree.** The proof and the allocation are two reads of one\n  publication, so a disagreement means one is stale and guessing which would be\n  guessing with money. They are compared as instants, so the same moment written\n  with a different UTC offset is agreement rather than a conflict.\n- **Only the server's clock judges it.** The browser's is never consulted: a\n  device wrong by hours would refuse perfectly good claims, and trusting it to\n  permit one is worse. There is no fallback to a local clock.\n- **The instant must be one that exists.** Validation is not \"does it parse\".\n  `2026-02-30`, `2026-02-29` in a non-leap year, `2026-04-31` and\n  `2026-01-01T24:00:00` all match a plausible RFC3339 shape *and* parse to a\n  finite instant, silently landing on a different day — so every component is\n  range-checked against the real calendar before the value is parsed at all.\n  Padding is refused rather than trimmed, for the same reason a padded amount\n  is: it means the value did not come from the encoder it claims to.\n\nA claim at exactly `claimExpiresAt` is still open, matching the contract, which\nreverts only strictly after it.\n\n### BREAKING: `claim.send` now requires the allocation and the server clock\n\n`allocation` was optional and `serverTime` did not exist. Both are now\n**required**, and a call missing either fails closed before the wallet is\ntouched rather than sending.\n\n```ts\n// BEFORE — compiled, sent, and silently skipped every cross-check\nawait data.claim.send({ proof, target }, chainSigner);\nawait data.claim.send({ proof, target, allocation }, chainSigner);\n\n// AFTER\nconst allocations = await data.claim.list();\nawait data.claim.send(\n  { proof, target, allocation, serverTime: allocations.serverTime },\n  chainSigner,\n);\n```\n\nOmission is refused because of what it silently skipped. Without the\nallocation there is no second independent read to check the proof against, so\nthe server's `claimable` and `claimed` gates, the amount, the payout wallet and\nthe claim window all went unverified — a send with one argument missing was a\nsend with five checks missing. Without `serverTime` the claim window cannot be\nassessed at all, and an unassessed window has no send path.\n\nBoth values come from the same `claim.list()` response, so the migration is to\nkeep the response rather than just its `rewards` array.\n\n### Conformance\n\n`ALPHEA_CONNECT_CLAIM_LEAF_TYPES` and `ALPHEA_CONNECT_CLAIM_LEAF_FIELDS` are the\ncanonical tuple this package encodes. The published contract vectors — leaves,\ntrees, roots, proofs, malformed cases, and out-of-range rejects — are reproduced\nbyte for byte by the package tests. Set\n`ALPHEA_CONTRACTS_REWARD_MERKLE_FIXTURE` and\n`ALPHEA_CONTRACTS_V2_HANDOFF_FIXTURE` to the contracts fixtures to cross-check\nthe vendored copies against their source.\n\n## Sponsored claim\n\nThere are two ways a claim can happen and they are deliberately separate calls.\n`claim.send` asks a wallet to sign and pay. `claim.sponsored` asks Core to relay\nthe claim with ALPHEA paying the gas — so there is no wallet, no signature, no\nproof, and no calldata anywhere on this path.\n\n```ts\nconst allocations = await data.claim.list();\nconst allocation = allocations.rewards[0];\n\nconst submission = await data.claim.sponsored.submit({\n  roundId: allocation.roundId,\n  idempotencyKey: crypto.randomUUID(),\n});\n\nconst status = await data.claim.sponsored.status(submission.operationId);\n```\n\nTwo fields go out and nothing else. No wallet address, no amount, no proof, no\nroot, no chain, no distributor, no calldata, and no gas-payer choice: the caller\nis authorized by their bearer token and Core derives the rest. That absence is\nthe authority model, and the package tests assert it rather than assume it.\n\nThe `idempotencyKey` is **yours to mint** and this package will never generate\none. What a key means — which requests count as the same request, and when\nreuse is a conflict — is Core's rule, and an SDK inventing a key from the round\nand the caller would be guessing at that rule. A guess that collided would\nsilently suppress a second legitimate claim.\n\n### The state is the answer, not the status code\n\nLike redeem, a sponsored claim reports its business outcome in the body. A\nresolved promise means Core replied; it does not mean anything was claimed.\n\n| `state` | What is true |\n|---|---|\n| `ACCEPTED_FOR_RELAY` | Core took the request. Nothing is on a chain yet. |\n| `SUBMITTED` | A transaction exists, and `transactionHash` identifies it. |\n| `MINED` | A matching receipt was seen. Still not Core's verdict. |\n| `CORE_CONFIRMED` | Core's own Claim state says the claim happened. |\n| `FAILED` | The relay ran and did not succeed. |\n| `UNAVAILABLE` | The sponsor could not act. Not the caller's fault. |\n| `REFUSED` | Core declined; `refusal` says why. |\n| `RESET` | The caller cancelled this operation's relay authority. |\n\n`isSponsoredClaimConfirmed(state)` is true for `CORE_CONFIRMED` and nothing\nelse. **Mined is not confirmed.** A transaction can be in a block and the claim\nstill not be one Core recognizes, so a UI that treats `MINED` as done is telling\na user they have been paid on the strength of the wrong fact.\n\nThere is no `isPending`, no `isTerminal`, and no poller in this package. Whether\n`FAILED` is worth retrying and whether `UNAVAILABLE` is temporary are Core's\njudgements, and how often to ask is a product decision — so `status()` is a\nplain read you call when you want to know, and nothing here advances a state,\nderives one from elapsed time, or treats a transaction hash appearing as\nprogress.\n\nAn unrecognized state or refusal is **refused**, not folded into `UNSPECIFIED`.\nThe two mean opposite things — \"Core said unspecified\" is an answer, \"Core said\nsomething this build cannot read\" is the absence of one — so a member added to\nthe contract after your copy of this package was built reaches you as an error\nrather than as silence. `SPONSORED_CLAIM_REFUSAL_NONE`, in turn, is Core stating\nthere is no refusal, which is a third thing again and is never collapsed into\neither.\n\nAn *omitted* state or refusal is a different case and is accepted: a proto3 JSON\nencoder omits a field holding its default value, so absence is the contract\nsaying `UNSPECIFIED` rather than this package guessing.\n\n### Resetting, and claiming again explicitly\n\nA caller who no longer wants ALPHEA to relay their claim can cancel that\nauthority for their own operation, keep their place in the existing queue, and\nthen make **one fresh explicit claim**:\n\n```ts\n// 1. Cancel future relay authority. `expectedResetGeneration` is a\n//    precondition: pass the generation you read back, verbatim.\nconst reset = await data.claim.sponsored.reset({\n  operationId: submission.operationId,\n  expectedResetGeneration: status.resetGeneration,   // \"0\" if never reset\n});\n\n// 2. Claim again, explicitly, with a NEW key you mint.\nawait data.claim.sponsored.submit({\n  roundId: allocation.roundId,\n  idempotencyKey: crypto.randomUUID(),\n  resetOperationId: submission.operationId,\n  resetGeneration: reset.resetGeneration,\n});\n```\n\nStep 2 is yours to take. `reset()` cancels and returns; it does not requeue,\nresubmit, resume a relay, or mint the new idempotency key — and reusing the key\nfrom the claim you just reset is what `IDEMPOTENCY_CONFLICT` is for.\n\n**A reset does not clear the line.** It rejoins the existing queue entry rather\nthan replacing it, so nothing under `data.claim.queue` is touched, invalidated,\nor advanced by a reset.\n\nTwo fields go out and nothing else: `operation_id` and\n`expected_reset_generation`. No idempotency key — the generation *is* the\nprecondition — and above all no command key. A reset **asks** Core to stop\nrelaying; a client-minted token authorizing it would be this SDK issuing a\npermission that is Core's alone to grant.\n\nThe result is an ordinary status, decoded by the same decoder `status()` uses,\nso every rule below applies to it unchanged. **Do not assume it is `RESET`.**\nCore may report an operation that was already suspended, or one that has since\nbeen paid, and this package does not assert that a reset yields `RESET` or that\nthe generation becomes `expected + 1` — those are relations the RPC never\npromised. Read `state`.\n\n`RESET` is **not** a confirmation. `isSponsoredClaimConfirmed` is false for it,\nexactly as for `FAILED`. What differs is what the user may do next — the reward\nis not lost — but that is a product decision this package does not make.\n\n#### The reset generation is a string\n\n`resetGeneration` and `expectedResetGeneration` are **canonical decimal text**,\nnever numbers, and that is not a style choice. The wire type is `uint64`, which\ncarries 64 bits; a JavaScript number carries 53. Past 2^53 the conversion is\nsilent and lossy — it does not throw and does not saturate, it produces a *near*\nvalue — so a generation round-tripped through a number would submit a\nprecondition about a cancellation that never happened, and Core would evaluate\nit against the wrong generation rather than refuse it.\n\nSo: read it back, pass it through, compare it as a string (or widen it with\n`BigInt`). Do not add to it, and do not compute one.\n\nDecoding is strict in both directions. Absence is the proto3 default `\"0\"` — an\nencoder omits a zero-valued `uint64`, so an omitted generation *is* the contract\nsaying zero. A safe integer arriving as a JSON number is normalized to decimal\ntext. Everything else is refused rather than coerced: padded (`\"007\"`),\nfractional, exponent (`Number(\"1e3\")` is 1000), negative, empty, over `uint64`,\nthe wrong type, or two casings disagreeing. Zero is not a neutral fallback here\n— it is the claim that the operation was never reset — so an unreadable value\nmust not become it.\n\n`resetGeneration` also appears on ordinary `submit()` and `status()` results,\nwhere it reports `\"0\"` for an operation that has never been reset.\n\n#### Compatibility\n\nAn ordinary `submit({ roundId, idempotencyKey })` is **unchanged** and still\nsends exactly those two fields. The reset pair is optional *together* and\nreaches the wire only when you supply both — never as defaults, so no\n`reset_generation: \"0\"` appears on an ordinary claim.\n\nSupplying one without the other is `invalid_argument`, and the type makes it a\ncompile error too. So is an empty operation id, a malformed generation, or\ngeneration `\"0\"` on a submission: zero means no cancellation happened, which is\nthe ordinary path. `reset()` is the one place `\"0\"` is routine — it is what an\noperation that was never reset reports, so a first reset expects it.\n\nA reset-aware submission additionally requires its answer to identify itself:\nthe returned operation id must equal `resetOperationId` (a post-reset claim\nreuses that identity rather than starting a new one) and the returned\n`resetGeneration` must equal the one submitted. A generation-zero submission\nkeeps its existing response rules exactly.\n\n### Refusals\n\n`refusal` is a closed vocabulary, and the members are kept apart because they\nneed different words in a UI:\n\n| `refusal` | Meaning |\n|---|---|\n| `NO_ALLOCATION` | Nothing to claim in this round. |\n| `NOT_CLAIMABLE` | There is an allocation, but the round is not claimable. |\n| `ALREADY_CLAIMED` | Already claimed. The reward is not lost. |\n| `CANONICAL_INPUT_MISMATCH` | Core's freshly derived list/proof/target inputs disagreed. |\n| `IDEMPOTENCY_CONFLICT` | The key was reused for a materially different request. |\n| `SPONSOR_UNAVAILABLE` | The sponsor could not act. Worth retrying. |\n\n### What is refused rather than degraded\n\nElsewhere in this package a missing or wrong-typed field decodes to a zero value\nrather than throwing — those are read surfaces feeding a UI, and one odd field\nshould degrade one number on one card. The sponsored decoders do not get that\nlatitude, because every field here is one a caller branches on to decide whether\nsomeone's reward moved. On that surface the zero value is not a neutral\nfallback, it is a claim of its own: `\"\"` says Core sent no transaction, `0` says\nCore named no chain, `UNSPECIFIED` says Core named no state. Folding an\nunreadable value into one of those does not lose information, it fabricates a\ncalmer answer — exactly when the server has gone wrong and you most need to\nknow.\n\nSo the line is not absent-versus-present, and not tolerant-versus-strict:\n\n- **Absent is accepted**, and resolves to the field's proto3 default. An omitted\n  field — or an explicit `null` — *is* the default under the JSON encoding, so\n  refusing it would be refusing correct, ordinary responses.\n- **Present but unreadable is refused**: an unknown enum name, an out-of-range\n  or non-integer ordinal, a string field holding a number, a `transactionHash`\n  that is not 32 bytes of hex, a `chainId` that is not a canonical uint64\n  (`Number(\"1e3\")` is 1000, so a lenient read would invent a chain nobody\n  named).\n\nThree further refusals are about the shape of the answer rather than one field:\n\n- **A response that is not a JSON object.** An all-empty status reads as\n  `UNSPECIFIED`, which is indistinguishable from a legitimate answer about an\n  operation that has not started.\n- **A status that does not identify its operation.** The echoed `operationId`\n  must be present and exactly equal to the one requested — not absent, and not\n  equal-but-for-case. A response that declines to say which relay it describes\n  has not answered the question, and deciding that two ids differing in case\n  name the same operation is Core's rule to make, not this package's. The same\n  rule applies to `reset()`.\n- **A reset response carrying no operation.** It describes nothing: no state to\n  report, no identity to check, no generation to carry forward. Returning a\n  fully-defaulted status instead would hand you `UNSPECIFIED` at generation\n  `\"0\"` with an empty id, which reads exactly like a legitimate answer about an\n  operation that was never reset.\n- **A body carrying both casings of a field with different values.** Resolving\n  that by key order would make the answer a fact about argument order; on\n  `operationId` it would be a way to slip a mismatched identity past the check\n  above. Presence is about the key existing, so `null` under one casing and a\n  value under the other is a contradiction, not silence — a body saying two\n  different things rather than one thing once.\n- **`SUBMITTED` or `MINED` with no `transactionHash`.** This one is Core's rule,\n  not ours: \"Core never reports SUBMITTED without a hash, because a send whose\n  hash was lost is not a send anybody can follow up on\", and `MINED` is defined\n  as a receipt for *that exact hash*. Such a response claims a transaction\n  exists and then declines to name it. The rule stops exactly there —\n  `CORE_CONFIRMED` comes from Core's own claimed state for the leaf, a different\n  authority, so requiring a hash of it would be inventing policy the contract\n  does not state.\n\nEach throws the package's ordinary `AlpheaCodedError` with code `internal`, and\nnone of them echoes the offending value: it came off the network, and these\nerrors are rendered into pages end users look at.\n\nDecoding is a closed projection — exactly the fields the contract names are\ncopied out. A signer- or provider-shaped field the server should never have sent\ncannot reach a caller through this path, because the defence is the allowlist\nrather than a denylist that has to recognize whatever it was called.\n\n## Errors, and the one public reason\n\nEvery failed call throws an `AlpheaCodedError`: a stable `code`, the HTTP-like\n`status`, and a `requestId` to correlate with the server-side log. The server's\n`message` and `details` are read only to classify — they never reach the thrown\nerror, so branch on `code`, never on message text.\n\nThat leaves one gap this package closes deliberately. A signup-gated login is\nrefused with `permission_denied`, the same code as any other refusal, so an app\nwould have nothing to distinguish \"you need to sign up\" from \"you can't do\nthat\". One machine-readable reason therefore crosses the boundary:\n\n```ts\nimport { readAlpheaConnectPublicErrorReason } from \"@alphea/connect\";\n\ntry {\n  await auth.googleLogin(...);\n} catch (error) {\n  if (readAlpheaConnectPublicErrorReason(error) === \"signup_required\") {\n    showSignupPrompt();                 // first-time signup is disabled here\n  } else {\n    showGenericRefusal();\n  }\n}\n```\n\n`AlpheaConnectPublicErrorReason` is a closed union with exactly one member. The\nhelper takes `unknown`, so it is safe on anything a `catch` produced, and it\nreturns `undefined` for everything that is not the exact frozen label —\nincluding a different real server reason, a wrong-case value, a value that\nappears only in the message, and a response whose error detail and\n`Alphea-Error-Reason` header disagree with each other. A contradictory response\nis not the contract, so no reason is exposed rather than one of the two being\npicked.\n\nNothing else about an error changed: `code`, `status`, `requestId`, `conflict`,\nand `notFound` behave exactly as before, and no server message, detail, or\nmetadata value is exposed.\n\n## Redaction helpers\n\n`redactConnectSession` projects any session-shaped value down to presence\nbooleans and non-secret identifiers. `redactConnectPayload` returns a structural\ncopy with credential-bearing fields replaced. Both are safe to call on logging\nand error paths.\n\n```ts\nimport { redactConnectSession } from \"@alphea/connect\";\n\nlogger.info(\"session\", redactConnectSession(session));\n// { authority: \"connect_user_session\", authenticated: true, renewable: true }\n```\n\n## Scope\n\nThis release covers the package workspace, the authority table, the transport\nand client shells, the redaction helpers, the auth/session surface, the session\ncoordinator, the points, referral, rounds, and redeem data surface, wallet\nbinding, reward claim — proof verification, calldata, the single guarded send\nsite, transaction reconciliation, and the sponsored relay mirror — and the store\npurchase/entitlement contract.\n\nOut of scope on the store path specifically: this package does not perform a\npurchase, open a store, call a provider, hold a provider credential, acknowledge\na token, grant, revoke or restore an entitlement, cache an entitlement list, or\nretry a submission. It sends one proof once and reports Core's verdict. Which\nplatforms and products a given environment serves is that deployment's answer,\ndelivered as its own coded error.\n\nOut of scope on the sponsored path specifically: this package does not relay,\nsponsor, sign, or pay for anything. It mirrors Core's submit, status and reset\noperations and decodes their answers; the relayer, the gas, the idempotency\nrule, whether a reset is permitted, and the claim's confirmation are Core's.\n\nOut of scope on the reset seam specifically: this package does not reset a\nqueue, mint a command key, infer state progress, resume a relay, requeue,\nresubmit, advance a generation, or offer a raw RPC escape hatch. Cancel and\nclaim-again are two calls a caller makes, in that order, deliberately.\n\nOut of scope by design: wallet-provider UI, any form of server-held or automatic\nsigning, key custody, and RPC endpoint configuration. The SDK holds no route to\na wallet or a node; both are supplied per call by the app.\n","readmeFilename":"README.md"}