@opendatalabs/vana-sdk 3.22.0 → 3.23.0-pr.211.341f55d

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/crypto/envelope/job.cjs +355 -0
  2. package/dist/crypto/envelope/job.cjs.map +1 -0
  3. package/dist/crypto/envelope/job.d.ts +123 -0
  4. package/dist/crypto/envelope/job.js +333 -0
  5. package/dist/crypto/envelope/job.js.map +1 -0
  6. package/dist/direct/access-request-client.cjs +4 -0
  7. package/dist/direct/access-request-client.cjs.map +1 -1
  8. package/dist/direct/access-request-client.js +4 -0
  9. package/dist/direct/access-request-client.js.map +1 -1
  10. package/dist/direct/connect-flow.cjs +43 -1
  11. package/dist/direct/connect-flow.cjs.map +1 -1
  12. package/dist/direct/connect-flow.d.ts +12 -0
  13. package/dist/direct/connect-flow.js +43 -1
  14. package/dist/direct/connect-flow.js.map +1 -1
  15. package/dist/direct/types.cjs.map +1 -1
  16. package/dist/direct/types.d.ts +7 -0
  17. package/dist/direct/types.js.map +1 -1
  18. package/dist/direct/use-direct-vana-connect.cjs +2 -1
  19. package/dist/direct/use-direct-vana-connect.cjs.map +1 -1
  20. package/dist/direct/use-direct-vana-connect.d.ts +3 -1
  21. package/dist/direct/use-direct-vana-connect.js +2 -1
  22. package/dist/direct/use-direct-vana-connect.js.map +1 -1
  23. package/dist/errors.cjs +84 -0
  24. package/dist/errors.cjs.map +1 -1
  25. package/dist/errors.d.ts +133 -0
  26. package/dist/errors.js +73 -0
  27. package/dist/errors.js.map +1 -1
  28. package/dist/index.browser.d.ts +3 -0
  29. package/dist/index.browser.js +631 -24
  30. package/dist/index.browser.js.map +4 -4
  31. package/dist/index.node.cjs +1259 -111
  32. package/dist/index.node.cjs.map +4 -4
  33. package/dist/index.node.d.ts +4 -0
  34. package/dist/index.node.js +1223 -101
  35. package/dist/index.node.js.map +4 -4
  36. package/dist/protocol/identity.cjs +219 -0
  37. package/dist/protocol/identity.cjs.map +1 -0
  38. package/dist/protocol/identity.d.ts +193 -0
  39. package/dist/protocol/identity.js +183 -0
  40. package/dist/protocol/identity.js.map +1 -0
  41. package/dist/protocol/identity.test.d.ts +1 -0
  42. package/dist/protocol/identity.vector.test.d.ts +1 -0
  43. package/dist/protocol/jobs-client.cjs +540 -0
  44. package/dist/protocol/jobs-client.cjs.map +1 -0
  45. package/dist/protocol/jobs-client.d.ts +169 -0
  46. package/dist/protocol/jobs-client.js +547 -0
  47. package/dist/protocol/jobs-client.js.map +1 -0
  48. package/dist/protocol/jobs-client.test.d.ts +1 -0
  49. package/dist/protocol/jobs.cjs +64 -0
  50. package/dist/protocol/jobs.cjs.map +1 -0
  51. package/dist/protocol/jobs.d.ts +193 -0
  52. package/dist/protocol/jobs.js +31 -0
  53. package/dist/protocol/jobs.js.map +1 -0
  54. package/dist/protocol/jobs.test.d.ts +1 -0
  55. package/dist/react.cjs.map +1 -1
  56. package/dist/react.d.ts +1 -1
  57. package/dist/react.js.map +1 -1
  58. package/package.json +7 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/direct/access-request-client.ts"],"sourcesContent":["/**\n * Default client for the Vana Account access-request API.\n *\n * @remarks\n * Calls the Vana Account endpoints that issue `dcr_*` ids and approval URLs and\n * report request status. Inject a custom {@link AccessRequestClient} on the\n * controller to point at a different deployment; pass `fetchFn` to supply a test\n * double for the HTTP layer.\n *\n * @category Direct\n * @module direct/access-request-client\n */\n\nimport type {\n AccessRequest,\n AccessRequestClient,\n AccessRequestQuestion,\n AccessRequestStatus,\n AccessRequestStatusValue,\n DirectEnv,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\nimport type { Web3SignedSignFn } from \"../auth/web3-signed-builder\";\nimport { parseScope, type ParsedScope } from \"../protocol/scopes\";\nimport { DirectConfigError } from \"./errors\";\n\n/** Minimal `fetch` signature so the client is testable without a global fetch. */\nexport type FetchLike = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n statusText: string;\n json(): Promise<unknown>;\n text(): Promise<string>;\n}>;\n\n/** Options for {@link createDefaultAccessRequestClient}. */\nexport interface DefaultAccessRequestClientOptions {\n /** Base URL of the Vana Account access-request API. */\n baseUrl: string;\n /** Base URL the user is sent to for approval. */\n approvalBaseUrl: string;\n /**\n * Target environment. Pins the allowed mobile continuation link host\n * (`open.vana.org` for production, `open-dev.vana.org` for dev). When omitted,\n * both canonical hosts pass the structural continuation-URL check.\n */\n env?: DirectEnv;\n /** `fetch` implementation. Defaults to the global `fetch`. */\n fetchFn?: FetchLike;\n /** App identity address used for direct access-request authentication. */\n appAddress?: string;\n /** EIP-191 signer for direct access-request authentication. */\n signMessage?: Web3SignedSignFn;\n /** Clock source used for signed request timestamps. */\n now?: () => number;\n /**\n * Create the signed DCR idempotency key used when a create call omits one.\n * Called once per create. Injectable for deterministic tests.\n */\n createIdempotencyKey?: () => string;\n}\n\nconst VALID_STATUSES: readonly AccessRequestStatusValue[] = [\n \"pending\",\n \"approved\",\n \"ready_for_read\",\n \"completed\",\n \"denied\",\n \"expired\",\n];\n\nfunction normalizeStatus(value: unknown): AccessRequestStatusValue {\n return VALID_STATUSES.includes(value as AccessRequestStatusValue)\n ? (value as AccessRequestStatusValue)\n : \"pending\";\n}\n\nfunction normalizeNetwork(value: unknown): AccessRequest[\"network\"] {\n return value === \"mainnet\" || value === \"moksha\" ? value : undefined;\n}\n\nfunction normalizeExpiresAt(value: unknown): string | undefined {\n return typeof value === \"string\" && Number.isFinite(Date.parse(value))\n ? value\n : undefined;\n}\n\nfunction defaultCreateIdempotencyKey(): string {\n if (typeof globalThis.crypto?.randomUUID !== \"function\") {\n throw new Error(\n \"Secure randomUUID is unavailable. Pass createIdempotencyKey to createDefaultAccessRequestClient.\",\n );\n }\n return globalThis.crypto.randomUUID();\n}\n\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\nconst DIRECT_ACCESS_REQUEST_MESSAGE_PREFIX = \"Vana Direct Access Request v1\";\n\ninterface DirectAccessRequestAuthInput {\n body: string;\n method: string;\n path: string;\n timestamp: string;\n}\n\nexport function buildDirectAccessRequestAuthMessage(\n input: DirectAccessRequestAuthInput,\n): string {\n return [\n DIRECT_ACCESS_REQUEST_MESSAGE_PREFIX,\n `method:${input.method.toUpperCase()}`,\n `path:${input.path}`,\n `timestamp:${input.timestamp}`,\n `body:${input.body}`,\n ].join(\"\\n\");\n}\n\nasync function buildDirectAccessRequestHeaders(\n options: DefaultAccessRequestClientOptions,\n input: Omit<DirectAccessRequestAuthInput, \"timestamp\">,\n): Promise<Record<string, string>> {\n if (!options.appAddress && !options.signMessage) {\n return {};\n }\n if (!options.appAddress || !options.signMessage) {\n throw new Error(\n \"Direct access-request authentication requires both `appAddress` and `signMessage`.\",\n );\n }\n\n const timestamp = String(options.now?.() ?? Date.now());\n const signature = await options.signMessage(\n buildDirectAccessRequestAuthMessage({ ...input, timestamp }),\n );\n\n return {\n \"X-Vana-App-Address\": options.appAddress,\n \"X-Vana-App-Signature\": signature,\n \"X-Vana-App-Timestamp\": timestamp,\n };\n}\n\n/**\n * Build an approval URL for a request id, matching the documented format\n * (`{app}/data-connection-requests/{requestId}?mode=page`).\n *\n * @param approvalBaseUrl - Base URL of the Vana approval app.\n * @param requestId - The `dcr_*` request id.\n * @returns The full approval URL.\n */\nexport function buildApprovalUrl(\n approvalBaseUrl: string,\n requestId: string,\n): string {\n return `${stripTrailingSlash(approvalBaseUrl)}/data-connection-requests/${encodeURIComponent(\n requestId,\n )}?mode=page`;\n}\n\n/** The `recompute` values the question contract defines today. */\nconst RECOMPUTE_VALUES: readonly string[] = [\"snapshot\", \"on-change\"];\n\nfunction parseConcreteScope(field: string, value: unknown): ParsedScope {\n if (typeof value !== \"string\") {\n throw new DirectConfigError(`${field} must be a string`, { field });\n }\n try {\n return parseScope(value);\n } catch {\n throw new DirectConfigError(\n `${field} \"${value}\" is not a concrete scope. Use {source}.{category}[.{subcategory}] with no wildcard and no operation prefix.`,\n { field, value },\n );\n }\n}\n\n/**\n * Validate the derivative questions on a create input against the request\n * scope entries.\n *\n * @remarks\n * Client-side mirror of the access-request service rules so builders fail\n * fast, before the create request is signed and sent; the service remains\n * authoritative. Rules: 1 to 4 questions; every `derivedScope` and every\n * `sourceScope` is a concrete scope (wildcards rejected); 1 to 16 source\n * scopes per question with no duplicates and none equal to the derived scope;\n * the first dot-segment of the derived scope differs from the first\n * dot-segment of every source scope; the derived scope appears verbatim in\n * `scopes` as a bare read entry; no two questions share a derived scope; the\n * question text is 1 to 4000 characters after trimming; `recompute`, when\n * present, is `\"snapshot\"` or `\"on-change\"`.\n *\n * @param questions - The `questions` array from the create input.\n * @param scopes - The request's grant scope entries, verbatim.\n * @throws {DirectConfigError} - When any rule is violated. The message names\n * the offending question index and field.\n */\nexport function validateAccessRequestQuestions(\n questions: readonly AccessRequestQuestion[],\n scopes: readonly string[],\n): void {\n if (questions.length === 0 || questions.length > 4) {\n throw new DirectConfigError(\n `questions must contain 1 to 4 entries when present, got ${questions.length}. Omit the field to send no questions.`,\n { count: questions.length },\n );\n }\n const seenDerived = new Set<string>();\n questions.forEach((question, index) => {\n const label = `questions[${index}]`;\n const derived = parseConcreteScope(\n `${label}.derivedScope`,\n question.derivedScope,\n );\n if (seenDerived.has(question.derivedScope)) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" is already used by an earlier question. Each question must target its own derived scope.`,\n { derivedScope: question.derivedScope },\n );\n }\n seenDerived.add(question.derivedScope);\n // The bare entry (no operation prefix) is what makes the answer readable\n // by the app: `write:coach.weekly` alone would not grant the read back.\n if (!scopes.includes(question.derivedScope)) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" must also appear in scopes as a bare read entry, so the app can read the answer it asked for.`,\n { derivedScope: question.derivedScope, scopes: [...scopes] },\n );\n }\n if (\n question.sourceScopes.length === 0 ||\n question.sourceScopes.length > 16\n ) {\n throw new DirectConfigError(\n `${label}.sourceScopes must contain 1 to 16 entries, got ${question.sourceScopes.length}.`,\n { count: question.sourceScopes.length },\n );\n }\n const seenSources = new Set<string>();\n for (const sourceScope of question.sourceScopes) {\n const source = parseConcreteScope(`${label}.sourceScopes`, sourceScope);\n if (seenSources.has(sourceScope)) {\n throw new DirectConfigError(\n `${label}.sourceScopes contains \"${sourceScope}\" more than once. Deduplicate the source scopes.`,\n { sourceScope },\n );\n }\n seenSources.add(sourceScope);\n if (sourceScope === question.derivedScope) {\n throw new DirectConfigError(\n `${label}.sourceScopes must not contain the derived scope \"${question.derivedScope}\".`,\n { sourceScope },\n );\n }\n if (source.source === derived.source) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" must not share its first dot-segment \"${derived.source}\" with source scope \"${sourceScope}\". Name the derived scope under the app's own namespace.`,\n { derivedScope: question.derivedScope, sourceScope },\n );\n }\n }\n if (typeof question.question !== \"string\") {\n throw new DirectConfigError(`${label}.question must be a string`, {\n field: `${label}.question`,\n });\n }\n const trimmedLength = question.question.trim().length;\n if (trimmedLength === 0 || trimmedLength > 4000) {\n throw new DirectConfigError(\n `${label}.question must be 1 to 4000 characters after trimming, got ${trimmedLength}.`,\n { length: trimmedLength },\n );\n }\n if (\n question.recompute !== undefined &&\n !RECOMPUTE_VALUES.includes(question.recompute)\n ) {\n throw new DirectConfigError(\n `${label}.recompute must be \"snapshot\" or \"on-change\" when present, got \"${String(question.recompute)}\".`,\n { recompute: question.recompute },\n );\n }\n });\n}\n\n/**\n * Create the default {@link AccessRequestClient} for the Vana Account\n * access-request API.\n *\n * @param options - Base URLs and an optional `fetch` implementation.\n * @returns An {@link AccessRequestClient} backed by HTTP calls.\n */\nexport function createDefaultAccessRequestClient(\n options: DefaultAccessRequestClientOptions,\n): AccessRequestClient {\n const fetchFn = options.fetchFn ?? (globalThis.fetch as FetchLike);\n if (!fetchFn) {\n throw new Error(\n \"No fetch implementation available. Pass `fetchFn` to createDefaultAccessRequestClient.\",\n );\n }\n const base = stripTrailingSlash(options.baseUrl);\n\n return {\n async createAccessRequest(input): Promise<AccessRequest> {\n if (input.questions !== undefined) {\n validateAccessRequestQuestions(input.questions, input.scopes);\n }\n const path = \"/api/data-connection-requests\";\n // Every call is an independent logical create, so it gets its own key.\n // The client cannot tell two look-alike creates apart — one shared backend\n // controller serves many users with the same app, scopes, and returnUrl —\n // so deriving a key from the input would let the service deduplicate two\n // users onto a single DCR. Retrying an uncertain create is the caller's\n // decision: pass the same `idempotencyKey` back in.\n const idempotencyKey =\n input.idempotencyKey ??\n (options.createIdempotencyKey ?? defaultCreateIdempotencyKey)();\n const body = JSON.stringify({\n appAddress: input.appAddress,\n app: input.app,\n source: input.source,\n scopes: input.scopes,\n returnUrl: input.returnUrl,\n network: input.network,\n ...(input.foregroundDelivery !== undefined\n ? { foregroundDelivery: input.foregroundDelivery }\n : {}),\n ...(input.questions !== undefined\n ? { questions: input.questions }\n : {}),\n idempotencyKey,\n });\n const res = await fetchFn(`${base}${path}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(await buildDirectAccessRequestHeaders(options, {\n body,\n method: \"POST\",\n path,\n })),\n },\n body,\n });\n if (!res.ok) {\n throw new Error(\n `Access request service error: ${res.status} ${res.statusText}`,\n );\n }\n const responseBody = (await res.json()) as {\n requestId?: string;\n id?: string;\n approvalUrl?: string;\n appAddress?: string;\n network?: unknown;\n expiresAt?: unknown;\n mobileContinuationUrl?: unknown;\n };\n const requestId = responseBody.requestId ?? responseBody.id;\n if (!requestId) {\n throw new Error(\"Access request service returned no requestId\");\n }\n return {\n requestId,\n approvalUrl:\n responseBody.approvalUrl ??\n buildApprovalUrl(options.approvalBaseUrl, requestId),\n appAddress: responseBody.appAddress ?? input.appAddress,\n network: normalizeNetwork(responseBody.network),\n expiresAt: normalizeExpiresAt(responseBody.expiresAt),\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n responseBody.mobileContinuationUrl,\n options.env,\n ),\n };\n },\n\n async getAccessRequestStatus(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n const path = `/api/data-connection-requests/${encodeURIComponent(requestId)}`;\n const res = await fetchFn(`${base}${path}`, {\n method: \"GET\",\n headers: await buildDirectAccessRequestHeaders(options, {\n body: \"\",\n method: \"GET\",\n path,\n }),\n });\n if (!res.ok) {\n throw new Error(\n `Access request service error: ${res.status} ${res.statusText}`,\n );\n }\n const body = (await res.json()) as {\n status?: string;\n personalServerUrl?: string;\n grantId?: string;\n scope?: string;\n mobileContinuationUrl?: unknown;\n scopes?: string[];\n };\n // `scopes` is the full approved set; `scope` is the first of them, kept\n // for callers (and deployments) that predate the array.\n const scopes =\n body.scopes && body.scopes.length > 0\n ? body.scopes\n : body.scope\n ? [body.scope]\n : undefined;\n return {\n status: normalizeStatus(body.status),\n personalServerUrl: body.personalServerUrl,\n grantId: body.grantId,\n scope: body.scope ?? scopes?.[0],\n scopes,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n body.mobileContinuationUrl,\n options.env,\n ),\n };\n },\n\n async acknowledgeRead(requestId: string): Promise<void> {\n const path = `/api/data-connection-requests/${encodeURIComponent(requestId)}/consumer-ack`;\n const res = await fetchFn(`${base}${path}`, {\n method: \"POST\",\n headers: await buildDirectAccessRequestHeaders(options, {\n body: \"\",\n method: \"POST\",\n path,\n }),\n });\n if (!res.ok) {\n throw new Error(\n `Access request ack service error: ${res.status} ${res.statusText}`,\n );\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/direct/access-request-client.ts"],"sourcesContent":["/**\n * Default client for the Vana Account access-request API.\n *\n * @remarks\n * Calls the Vana Account endpoints that issue `dcr_*` ids and approval URLs and\n * report request status. Inject a custom {@link AccessRequestClient} on the\n * controller to point at a different deployment; pass `fetchFn` to supply a test\n * double for the HTTP layer.\n *\n * @category Direct\n * @module direct/access-request-client\n */\n\nimport type {\n AccessRequest,\n AccessRequestClient,\n AccessRequestDelivery,\n AccessRequestQuestion,\n AccessRequestStatus,\n AccessRequestStatusValue,\n DirectEnv,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\nimport type { Web3SignedSignFn } from \"../auth/web3-signed-builder\";\nimport { parseScope, type ParsedScope } from \"../protocol/scopes\";\nimport { DirectConfigError } from \"./errors\";\n\n/** Minimal `fetch` signature so the client is testable without a global fetch. */\nexport type FetchLike = (\n input: string,\n init?: {\n method?: string;\n headers?: Record<string, string>;\n body?: string;\n },\n) => Promise<{\n ok: boolean;\n status: number;\n statusText: string;\n json(): Promise<unknown>;\n text(): Promise<string>;\n}>;\n\n/** Options for {@link createDefaultAccessRequestClient}. */\nexport interface DefaultAccessRequestClientOptions {\n /** Base URL of the Vana Account access-request API. */\n baseUrl: string;\n /** Base URL the user is sent to for approval. */\n approvalBaseUrl: string;\n /**\n * Target environment. Pins the allowed mobile continuation link host\n * (`open.vana.org` for production, `open-dev.vana.org` for dev). When omitted,\n * both canonical hosts pass the structural continuation-URL check.\n */\n env?: DirectEnv;\n /** `fetch` implementation. Defaults to the global `fetch`. */\n fetchFn?: FetchLike;\n /** App identity address used for direct access-request authentication. */\n appAddress?: string;\n /** EIP-191 signer for direct access-request authentication. */\n signMessage?: Web3SignedSignFn;\n /** Clock source used for signed request timestamps. */\n now?: () => number;\n /**\n * Create the signed DCR idempotency key used when a create call omits one.\n * Called once per create. Injectable for deterministic tests.\n */\n createIdempotencyKey?: () => string;\n}\n\nconst VALID_STATUSES: readonly AccessRequestStatusValue[] = [\n \"pending\",\n \"approved\",\n \"ready_for_read\",\n \"completed\",\n \"denied\",\n \"expired\",\n];\n\nfunction normalizeStatus(value: unknown): AccessRequestStatusValue {\n return VALID_STATUSES.includes(value as AccessRequestStatusValue)\n ? (value as AccessRequestStatusValue)\n : \"pending\";\n}\n\nfunction normalizeDelivery(value: unknown): AccessRequestDelivery | undefined {\n return value === \"enclave\" || value === \"personal_server\" ? value : undefined;\n}\n\nfunction normalizeNetwork(value: unknown): AccessRequest[\"network\"] {\n return value === \"mainnet\" || value === \"moksha\" ? value : undefined;\n}\n\nfunction normalizeExpiresAt(value: unknown): string | undefined {\n return typeof value === \"string\" && Number.isFinite(Date.parse(value))\n ? value\n : undefined;\n}\n\nfunction defaultCreateIdempotencyKey(): string {\n if (typeof globalThis.crypto?.randomUUID !== \"function\") {\n throw new Error(\n \"Secure randomUUID is unavailable. Pass createIdempotencyKey to createDefaultAccessRequestClient.\",\n );\n }\n return globalThis.crypto.randomUUID();\n}\n\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, \"\");\n}\n\nconst DIRECT_ACCESS_REQUEST_MESSAGE_PREFIX = \"Vana Direct Access Request v1\";\n\ninterface DirectAccessRequestAuthInput {\n body: string;\n method: string;\n path: string;\n timestamp: string;\n}\n\nexport function buildDirectAccessRequestAuthMessage(\n input: DirectAccessRequestAuthInput,\n): string {\n return [\n DIRECT_ACCESS_REQUEST_MESSAGE_PREFIX,\n `method:${input.method.toUpperCase()}`,\n `path:${input.path}`,\n `timestamp:${input.timestamp}`,\n `body:${input.body}`,\n ].join(\"\\n\");\n}\n\nasync function buildDirectAccessRequestHeaders(\n options: DefaultAccessRequestClientOptions,\n input: Omit<DirectAccessRequestAuthInput, \"timestamp\">,\n): Promise<Record<string, string>> {\n if (!options.appAddress && !options.signMessage) {\n return {};\n }\n if (!options.appAddress || !options.signMessage) {\n throw new Error(\n \"Direct access-request authentication requires both `appAddress` and `signMessage`.\",\n );\n }\n\n const timestamp = String(options.now?.() ?? Date.now());\n const signature = await options.signMessage(\n buildDirectAccessRequestAuthMessage({ ...input, timestamp }),\n );\n\n return {\n \"X-Vana-App-Address\": options.appAddress,\n \"X-Vana-App-Signature\": signature,\n \"X-Vana-App-Timestamp\": timestamp,\n };\n}\n\n/**\n * Build an approval URL for a request id, matching the documented format\n * (`{app}/data-connection-requests/{requestId}?mode=page`).\n *\n * @param approvalBaseUrl - Base URL of the Vana approval app.\n * @param requestId - The `dcr_*` request id.\n * @returns The full approval URL.\n */\nexport function buildApprovalUrl(\n approvalBaseUrl: string,\n requestId: string,\n): string {\n return `${stripTrailingSlash(approvalBaseUrl)}/data-connection-requests/${encodeURIComponent(\n requestId,\n )}?mode=page`;\n}\n\n/** The `recompute` values the question contract defines today. */\nconst RECOMPUTE_VALUES: readonly string[] = [\"snapshot\", \"on-change\"];\n\nfunction parseConcreteScope(field: string, value: unknown): ParsedScope {\n if (typeof value !== \"string\") {\n throw new DirectConfigError(`${field} must be a string`, { field });\n }\n try {\n return parseScope(value);\n } catch {\n throw new DirectConfigError(\n `${field} \"${value}\" is not a concrete scope. Use {source}.{category}[.{subcategory}] with no wildcard and no operation prefix.`,\n { field, value },\n );\n }\n}\n\n/**\n * Validate the derivative questions on a create input against the request\n * scope entries.\n *\n * @remarks\n * Client-side mirror of the access-request service rules so builders fail\n * fast, before the create request is signed and sent; the service remains\n * authoritative. Rules: 1 to 4 questions; every `derivedScope` and every\n * `sourceScope` is a concrete scope (wildcards rejected); 1 to 16 source\n * scopes per question with no duplicates and none equal to the derived scope;\n * the first dot-segment of the derived scope differs from the first\n * dot-segment of every source scope; the derived scope appears verbatim in\n * `scopes` as a bare read entry; no two questions share a derived scope; the\n * question text is 1 to 4000 characters after trimming; `recompute`, when\n * present, is `\"snapshot\"` or `\"on-change\"`.\n *\n * @param questions - The `questions` array from the create input.\n * @param scopes - The request's grant scope entries, verbatim.\n * @throws {DirectConfigError} - When any rule is violated. The message names\n * the offending question index and field.\n */\nexport function validateAccessRequestQuestions(\n questions: readonly AccessRequestQuestion[],\n scopes: readonly string[],\n): void {\n if (questions.length === 0 || questions.length > 4) {\n throw new DirectConfigError(\n `questions must contain 1 to 4 entries when present, got ${questions.length}. Omit the field to send no questions.`,\n { count: questions.length },\n );\n }\n const seenDerived = new Set<string>();\n questions.forEach((question, index) => {\n const label = `questions[${index}]`;\n const derived = parseConcreteScope(\n `${label}.derivedScope`,\n question.derivedScope,\n );\n if (seenDerived.has(question.derivedScope)) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" is already used by an earlier question. Each question must target its own derived scope.`,\n { derivedScope: question.derivedScope },\n );\n }\n seenDerived.add(question.derivedScope);\n // The bare entry (no operation prefix) is what makes the answer readable\n // by the app: `write:coach.weekly` alone would not grant the read back.\n if (!scopes.includes(question.derivedScope)) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" must also appear in scopes as a bare read entry, so the app can read the answer it asked for.`,\n { derivedScope: question.derivedScope, scopes: [...scopes] },\n );\n }\n if (\n question.sourceScopes.length === 0 ||\n question.sourceScopes.length > 16\n ) {\n throw new DirectConfigError(\n `${label}.sourceScopes must contain 1 to 16 entries, got ${question.sourceScopes.length}.`,\n { count: question.sourceScopes.length },\n );\n }\n const seenSources = new Set<string>();\n for (const sourceScope of question.sourceScopes) {\n const source = parseConcreteScope(`${label}.sourceScopes`, sourceScope);\n if (seenSources.has(sourceScope)) {\n throw new DirectConfigError(\n `${label}.sourceScopes contains \"${sourceScope}\" more than once. Deduplicate the source scopes.`,\n { sourceScope },\n );\n }\n seenSources.add(sourceScope);\n if (sourceScope === question.derivedScope) {\n throw new DirectConfigError(\n `${label}.sourceScopes must not contain the derived scope \"${question.derivedScope}\".`,\n { sourceScope },\n );\n }\n if (source.source === derived.source) {\n throw new DirectConfigError(\n `${label}.derivedScope \"${question.derivedScope}\" must not share its first dot-segment \"${derived.source}\" with source scope \"${sourceScope}\". Name the derived scope under the app's own namespace.`,\n { derivedScope: question.derivedScope, sourceScope },\n );\n }\n }\n if (typeof question.question !== \"string\") {\n throw new DirectConfigError(`${label}.question must be a string`, {\n field: `${label}.question`,\n });\n }\n const trimmedLength = question.question.trim().length;\n if (trimmedLength === 0 || trimmedLength > 4000) {\n throw new DirectConfigError(\n `${label}.question must be 1 to 4000 characters after trimming, got ${trimmedLength}.`,\n { length: trimmedLength },\n );\n }\n if (\n question.recompute !== undefined &&\n !RECOMPUTE_VALUES.includes(question.recompute)\n ) {\n throw new DirectConfigError(\n `${label}.recompute must be \"snapshot\" or \"on-change\" when present, got \"${String(question.recompute)}\".`,\n { recompute: question.recompute },\n );\n }\n });\n}\n\n/**\n * Create the default {@link AccessRequestClient} for the Vana Account\n * access-request API.\n *\n * @param options - Base URLs and an optional `fetch` implementation.\n * @returns An {@link AccessRequestClient} backed by HTTP calls.\n */\nexport function createDefaultAccessRequestClient(\n options: DefaultAccessRequestClientOptions,\n): AccessRequestClient {\n const fetchFn = options.fetchFn ?? (globalThis.fetch as FetchLike);\n if (!fetchFn) {\n throw new Error(\n \"No fetch implementation available. Pass `fetchFn` to createDefaultAccessRequestClient.\",\n );\n }\n const base = stripTrailingSlash(options.baseUrl);\n\n return {\n async createAccessRequest(input): Promise<AccessRequest> {\n if (input.questions !== undefined) {\n validateAccessRequestQuestions(input.questions, input.scopes);\n }\n const path = \"/api/data-connection-requests\";\n // Every call is an independent logical create, so it gets its own key.\n // The client cannot tell two look-alike creates apart — one shared backend\n // controller serves many users with the same app, scopes, and returnUrl —\n // so deriving a key from the input would let the service deduplicate two\n // users onto a single DCR. Retrying an uncertain create is the caller's\n // decision: pass the same `idempotencyKey` back in.\n const idempotencyKey =\n input.idempotencyKey ??\n (options.createIdempotencyKey ?? defaultCreateIdempotencyKey)();\n const body = JSON.stringify({\n appAddress: input.appAddress,\n app: input.app,\n source: input.source,\n scopes: input.scopes,\n returnUrl: input.returnUrl,\n network: input.network,\n ...(input.foregroundDelivery !== undefined\n ? { foregroundDelivery: input.foregroundDelivery }\n : {}),\n ...(input.questions !== undefined\n ? { questions: input.questions }\n : {}),\n idempotencyKey,\n });\n const res = await fetchFn(`${base}${path}`, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...(await buildDirectAccessRequestHeaders(options, {\n body,\n method: \"POST\",\n path,\n })),\n },\n body,\n });\n if (!res.ok) {\n throw new Error(\n `Access request service error: ${res.status} ${res.statusText}`,\n );\n }\n const responseBody = (await res.json()) as {\n requestId?: string;\n id?: string;\n approvalUrl?: string;\n appAddress?: string;\n network?: unknown;\n expiresAt?: unknown;\n mobileContinuationUrl?: unknown;\n };\n const requestId = responseBody.requestId ?? responseBody.id;\n if (!requestId) {\n throw new Error(\"Access request service returned no requestId\");\n }\n return {\n requestId,\n approvalUrl:\n responseBody.approvalUrl ??\n buildApprovalUrl(options.approvalBaseUrl, requestId),\n appAddress: responseBody.appAddress ?? input.appAddress,\n network: normalizeNetwork(responseBody.network),\n expiresAt: normalizeExpiresAt(responseBody.expiresAt),\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n responseBody.mobileContinuationUrl,\n options.env,\n ),\n };\n },\n\n async getAccessRequestStatus(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n const path = `/api/data-connection-requests/${encodeURIComponent(requestId)}`;\n const res = await fetchFn(`${base}${path}`, {\n method: \"GET\",\n headers: await buildDirectAccessRequestHeaders(options, {\n body: \"\",\n method: \"GET\",\n path,\n }),\n });\n if (!res.ok) {\n throw new Error(\n `Access request service error: ${res.status} ${res.statusText}`,\n );\n }\n const body = (await res.json()) as {\n status?: string;\n delivery?: unknown;\n personalServerUrl?: string;\n grantId?: string;\n scope?: string;\n mobileContinuationUrl?: unknown;\n scopes?: string[];\n };\n // `scopes` is the full approved set; `scope` is the first of them, kept\n // for callers (and deployments) that predate the array.\n const scopes =\n body.scopes && body.scopes.length > 0\n ? body.scopes\n : body.scope\n ? [body.scope]\n : undefined;\n return {\n status: normalizeStatus(body.status),\n delivery: normalizeDelivery(body.delivery),\n personalServerUrl: body.personalServerUrl,\n grantId: body.grantId,\n scope: body.scope ?? scopes?.[0],\n scopes,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n body.mobileContinuationUrl,\n options.env,\n ),\n };\n },\n\n async acknowledgeRead(requestId: string): Promise<void> {\n const path = `/api/data-connection-requests/${encodeURIComponent(requestId)}/consumer-ack`;\n const res = await fetchFn(`${base}${path}`, {\n method: \"POST\",\n headers: await buildDirectAccessRequestHeaders(options, {\n body: \"\",\n method: \"POST\",\n path,\n }),\n });\n if (!res.ok) {\n throw new Error(\n `Access request ack service error: ${res.status} ${res.statusText}`,\n );\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
@@ -78,6 +78,7 @@ function createDirectConnectFlow(transports, options = {}) {
78
78
  const listeners = /* @__PURE__ */ new Set();
79
79
  let pollHandle = null;
80
80
  let running = false;
81
+ let approvedRequest = null;
81
82
  let activeRunId = 0;
82
83
  let openedWindow = null;
83
84
  function emit() {
@@ -175,6 +176,7 @@ function createDirectConnectFlow(transports, options = {}) {
175
176
  }
176
177
  if (isReadReadyStatus(status.status)) {
177
178
  clearPoll();
179
+ approvedRequest = request;
178
180
  await readAndFinish(request);
179
181
  return;
180
182
  }
@@ -188,7 +190,7 @@ function createDirectConnectFlow(transports, options = {}) {
188
190
  }
189
191
  scheduleNextPoll(request, deadline);
190
192
  }
191
- return {
193
+ const flow = {
192
194
  getState() {
193
195
  return state;
194
196
  },
@@ -199,6 +201,7 @@ function createDirectConnectFlow(transports, options = {}) {
199
201
  async start() {
200
202
  if (running || isRunningPhase()) return;
201
203
  running = true;
204
+ approvedRequest = null;
202
205
  const runId = ++activeRunId;
203
206
  const browserPlatform = (options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()).current();
204
207
  const approvalWindow = browserPlatform === "desktop" ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)() : null;
@@ -246,14 +249,53 @@ function createDirectConnectFlow(transports, options = {}) {
246
249
  popupBlocked: approvalWindow === null
247
250
  });
248
251
  },
252
+ async retryRead() {
253
+ if (running || isRunningPhase()) {
254
+ throw new Error(
255
+ "Cannot retry a read while the connect flow is running"
256
+ );
257
+ }
258
+ const request = approvedRequest;
259
+ const parsedExpiry = request?.expiresAt ? Date.parse(request.expiresAt) : Number.NaN;
260
+ const requestExpired = Number.isFinite(parsedExpiry) && now() >= parsedExpiry;
261
+ if (request && !requestExpired) {
262
+ running = true;
263
+ const runId = ++activeRunId;
264
+ let status;
265
+ try {
266
+ status = await transports.getStatus(request.requestId);
267
+ } catch (err) {
268
+ if (runId !== activeRunId) {
269
+ throw new Error("Read retry was superseded");
270
+ }
271
+ running = false;
272
+ const error = toError(err);
273
+ setState({ type: "error", error });
274
+ throw error;
275
+ }
276
+ if (runId !== activeRunId) {
277
+ throw new Error("Read retry was superseded");
278
+ }
279
+ if (isReadReadyStatus(status.status)) {
280
+ await readAndFinish(request);
281
+ return "retried_existing_grant";
282
+ }
283
+ running = false;
284
+ }
285
+ approvedRequest = null;
286
+ await flow.start();
287
+ return "fresh_approval_required";
288
+ },
249
289
  reset() {
250
290
  running = false;
291
+ approvedRequest = null;
251
292
  activeRunId++;
252
293
  clearPoll();
253
294
  closeUnnavigatedWindow();
254
295
  setState({ type: "idle" });
255
296
  }
256
297
  };
298
+ return flow;
257
299
  }
258
300
  // Annotate the CommonJS export names for ESM import in node:
259
301
  0 && (module.exports = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/direct/connect-flow.ts"],"sourcesContent":["/**\n * Framework-agnostic connect-flow state machine for the browser two-tab helper.\n *\n * @remarks\n * This is the testable core behind {@link useDirectVanaConnect}. It is pure\n * TypeScript (no React, no DOM-only APIs beyond an injectable window opener and\n * timers) so the full flow — create request, open Vana, poll status, read data —\n * can be exercised in a Node test environment.\n *\n * The React hook is a thin `useSyncExternalStore` binding over this store.\n *\n * @category Direct\n * @module direct/connect-flow\n */\n\nimport type {\n AccessRequest,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\n\n/**\n * Caller-supplied transports. These typically `fetch` the app's own backend\n * routes, which in turn delegate to a {@link DirectDataController}.\n */\nexport interface DirectConnectTransports<T = unknown> {\n /** Ask the backend to create an access request. */\n createRequest: () => Promise<AccessRequest>;\n /** Ask the backend for the current status of a request. */\n getStatus: (requestId: string) => Promise<AccessRequestStatus>;\n /** Ask the backend to read the approved data. */\n readResult: (requestId: string) => Promise<ApprovedDataResult<T>>;\n}\n\n/**\n * A handle to a tab opened synchronously under the user's click gesture.\n *\n * @remarks\n * The flow opens this tab *before* it knows the approval URL (popup blockers\n * only allow `window.open()` during the click's transient activation), then\n * navigates it once `createRequest` resolves.\n */\nexport interface ConnectWindow {\n /** Point the already-open tab at the approval URL. */\n navigate(url: string): void;\n /** Close the tab (used to clean up an un-navigated tab on failure/reset). */\n close(): void;\n}\n\n/** Browser class used only to choose the destination returned by Vana. */\nexport type DirectBrowserPlatform = \"desktop\" | \"mobile\";\n\n/** Injectable browser-platform policy; it never asserts whether an app exists. */\nexport interface DirectBrowserPlatformPolicy {\n current(): DirectBrowserPlatform;\n}\n\n/** Tunables for the connect flow. */\nexport interface DirectConnectOptions {\n /** Status poll interval in ms. Defaults to 1500. */\n pollIntervalMs?: number;\n /**\n * Overall timeout in ms before giving up. Defaults to 300000 (5 min).\n * Used only when the access request does not carry an authoritative\n * `expiresAt` value.\n */\n timeoutMs?: number;\n /**\n * Synchronously open a blank tab under the click's transient activation and\n * return a handle to navigate later, or `null` if the browser blocked it.\n * Defaults to `window.open(\"\", \"_blank\")` (with `opener` severed). Injectable\n * for tests.\n *\n * @remarks\n * Renamed from the pre-3.8 `openWindow?: (url) => void`. The old contract was\n * the BUI-622 bug itself (it was called with the URL *after* an `await`, so\n * the popup blocker suppressed it); it cannot be preserved while fixing the\n * bug. Custom openers must now open synchronously and return a navigable\n * handle.\n */\n openApprovalWindow?: () => ConnectWindow | null;\n /** SDK-owned mobile/desktop policy. Injectable for deterministic tests. */\n browserPlatformPolicy?: DirectBrowserPlatformPolicy;\n /** `setTimeout`. Injectable for tests. Defaults to `globalThis.setTimeout`. */\n setTimeoutFn?: (cb: () => void, ms: number) => unknown;\n /** `clearTimeout`. Injectable for tests. Defaults to `globalThis.clearTimeout`. */\n clearTimeoutFn?: (handle: unknown) => void;\n /** Clock source in ms. Injectable for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Discriminated connect-flow state.\n *\n * @remarks\n * `type` matches the builder guide: it starts at `\"idle\"` and is non-idle while\n * connecting. The intermediate phases give richer UIs something to render.\n *\n * Desktop and light-data requests move through `\"awaiting_approval\"` (Vana Web\n * opens in a popup). A deep Direct request on a mobile browser moves through\n * `\"ready_to_open\"` instead: the SDK exposes a plain HTTPS\n * `mobileContinuationUrl` for the UI to render as a primary \"Open Vana\" link,\n * never launching it automatically, and keeps polling in memory.\n */\nexport type DirectConnectState<T = unknown> =\n | { type: \"idle\" }\n | { type: \"creating\" }\n | {\n type: \"awaiting_approval\";\n request: AccessRequest;\n /**\n * `true` when the popup was blocked. The UI should render the universal\n * HTTPS `request.approvalUrl` as a manual \"Open approval\" link.\n */\n popupBlocked: boolean;\n }\n | {\n type: \"ready_to_open\";\n request: AccessRequest;\n /**\n * Validated HTTPS continuation URL the mobile UI renders as the primary\n * \"Open Vana\" tap. Polling continues while it is shown; its embedded\n * ticket may rotate to a fresh URL between polls.\n */\n mobileContinuationUrl: string;\n }\n | { type: \"reading\"; request: AccessRequest }\n | { type: \"done\"; result: ApprovedDataResult<T> }\n | { type: \"error\"; error: Error };\n\n/** The store returned by {@link createDirectConnectFlow}. */\nexport interface DirectConnectFlow<T = unknown> {\n /** Current state. */\n getState(): DirectConnectState<T>;\n /** Subscribe to state changes; returns an unsubscribe function. */\n subscribe(listener: () => void): () => void;\n /** Begin the flow. No-op if already running. */\n start(): Promise<void>;\n /** Reset to `idle` and stop any in-flight polling. */\n reset(): void;\n}\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_TIMEOUT_MS = 300_000;\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\nconst MOBILE_USER_AGENT =\n /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle|Opera Mini|IEMobile/i;\n\nfunction defaultBrowserPlatformPolicy(): DirectBrowserPlatformPolicy {\n return {\n current() {\n if (typeof navigator === \"undefined\") return \"desktop\";\n const isTouchCapableIpad =\n navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1;\n return MOBILE_USER_AGENT.test(navigator.userAgent) || isTouchCapableIpad\n ? \"mobile\"\n : \"desktop\";\n },\n };\n}\n\n/**\n * Default {@link DirectConnectOptions.openApprovalWindow}: open a blank tab\n * synchronously (inside the click gesture) and return a handle to navigate\n * once the approval URL is known. Returns `null` when blocked or non-DOM.\n */\nfunction defaultOpenApprovalWindow(): ConnectWindow | null {\n if (typeof window === \"undefined\" || !window.open) return null;\n // We can't pass the \"noopener\"/\"noreferrer\" feature string here: it makes\n // window.open() return null, which would throw away the handle we need to\n // navigate later. So we open plain and re-create both protections by hand.\n const opened = window.open(\"\", \"_blank\");\n if (!opened) return null;\n // Sever the opener link while the tab is still about:blank, so the approval\n // page can't reach back into the app (reverse tab-nabbing).\n try {\n opened.opener = null;\n } catch {\n // Some environments make `opener` read-only; best-effort only.\n }\n return {\n navigate(url: string) {\n // Restore the no-referrer protection the old \"noreferrer\" feature gave:\n // tag the blank document so the upcoming navigation sends no Referer to\n // the approval page (best-effort; the blank doc is same-origin here).\n try {\n const meta = opened.document.createElement(\"meta\");\n meta.name = \"referrer\";\n meta.content = \"no-referrer\";\n (opened.document.head ?? opened.document.documentElement)?.appendChild(\n meta,\n );\n } catch {\n // Cross-origin/unavailable document: skip, navigation still proceeds.\n }\n opened.location.href = url;\n },\n close() {\n opened.close();\n },\n };\n}\n\n/**\n * Create a connect-flow store.\n *\n * @param transports - Backend transports (`createRequest`, `getStatus`, `readResult`).\n * @param options - Polling/timeout tunables and injectable side effects.\n * @returns A {@link DirectConnectFlow} store.\n */\nexport function createDirectConnectFlow<T = unknown>(\n transports: DirectConnectTransports<T>,\n options: DirectConnectOptions = {},\n): DirectConnectFlow<T> {\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n // `openApprovalWindow` and `browserPlatformPolicy` are resolved lazily at\n // start() (see below) so options swapped in after construction are still\n // honoured — matching the latest-callback pattern the React hook uses for its\n // transports.\n const setTimeoutFn =\n options.setTimeoutFn ??\n ((cb: () => void, ms: number) => globalThis.setTimeout(cb, ms));\n const clearTimeoutFn =\n options.clearTimeoutFn ??\n ((handle: unknown) => {\n globalThis.clearTimeout(handle as never);\n });\n const now = options.now ?? (() => Date.now());\n let state: DirectConnectState<T> = { type: \"idle\" };\n const listeners = new Set<() => void>();\n let pollHandle: unknown = null;\n let running = false;\n // Monotonic id for the current start() invocation. reset() (and an\n // immediately following start()) bumps it, so a previous run whose async\n // createRequest is still in flight can detect it has been superseded and\n // avoid touching shared state / the newer run's tab.\n let activeRunId = 0;\n // Holds the tab we opened only while it is still blank (un-navigated). Once\n // navigated to the approval URL we drop the reference so reset/cleanup never\n // closes the live approval tab the user is interacting with.\n let openedWindow: ConnectWindow | null = null;\n\n function emit(): void {\n for (const listener of listeners) listener();\n }\n\n function setState(next: DirectConnectState<T>): void {\n state = next;\n emit();\n }\n\n function clearPoll(): void {\n if (pollHandle !== null) {\n clearTimeoutFn(pollHandle);\n pollHandle = null;\n }\n }\n\n /** Close the opened tab if it is still blank (never navigated). */\n function closeUnnavigatedWindow(): void {\n if (openedWindow) {\n openedWindow.close();\n openedWindow = null;\n }\n }\n\n function isRunningPhase(): boolean {\n return (\n state.type === \"creating\" ||\n state.type === \"awaiting_approval\" ||\n state.type === \"ready_to_open\" ||\n state.type === \"reading\"\n );\n }\n\n async function readAndFinish(request: AccessRequest): Promise<void> {\n setState({ type: \"reading\", request });\n try {\n const result = await transports.readResult(request.requestId);\n if (!running) return;\n setState({ type: \"done\", result });\n } catch (err) {\n if (!running) return;\n setState({ type: \"error\", error: toError(err) });\n } finally {\n running = false;\n }\n }\n\n function scheduleNextPoll(request: AccessRequest, deadline: number): void {\n pollHandle = setTimeoutFn(() => {\n void poll(request, deadline);\n }, pollIntervalMs);\n }\n\n function requestDeadline(request: AccessRequest): number {\n if (request.expiresAt !== undefined) {\n const expiresAt = Date.parse(request.expiresAt);\n if (Number.isFinite(expiresAt)) return expiresAt;\n }\n return now() + timeoutMs;\n }\n\n /**\n * Enter the polling loop from the given initial state (either\n * `awaiting_approval` for desktop/light or `ready_to_open` for mobile-deep).\n * Errors out immediately if the request has already expired.\n */\n function startPolling(\n request: AccessRequest,\n initialState: DirectConnectState<T>,\n ): void {\n setState(initialState);\n const deadline = requestDeadline(request);\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Access request expired\"),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n async function poll(request: AccessRequest, deadline: number): Promise<void> {\n if (!running) return;\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Timed out waiting for approval\"),\n });\n return;\n }\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (!running) return;\n running = false;\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (!running) return;\n\n // A pending deep-mobile status may rotate the continuation ticket. Adopt a\n // fresh, still-valid URL so the rendered \"Open Vana\" link always points at a\n // live ticket; ignore it on the desktop/light path.\n if (status.status === \"pending\" && state.type === \"ready_to_open\") {\n const refreshed = normalizeMobileContinuationUrl(\n status.mobileContinuationUrl,\n );\n if (refreshed && refreshed !== state.mobileContinuationUrl) {\n request = { ...request, mobileContinuationUrl: refreshed };\n setState({\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl: refreshed,\n });\n }\n }\n\n if (isReadReadyStatus(status.status)) {\n clearPoll();\n await readAndFinish(request);\n return;\n }\n if (\n status.status === \"completed\" ||\n status.status === \"denied\" ||\n status.status === \"expired\"\n ) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(`Access request ${status.status}`),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n return {\n getState() {\n return state;\n },\n\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n\n async start(): Promise<void> {\n if (running || isRunningPhase()) return;\n running = true;\n const runId = ++activeRunId;\n // Read the platform policy at start time, like openApprovalWindow below,\n // so a policy swapped in after construction (a React rerender forwards\n // options through a ref) still decides this run's destination.\n const browserPlatform = (\n options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()\n ).current();\n\n // Desktop preserves the pre-mobile synchronous popup contract: open a\n // blank tab while the click's transient activation is live, then navigate\n // it once createRequest returns the approval URL (BUI-622). Mobile never\n // creates that transient tab; deep requests expose one explicit HTTPS\n // link, while light requests retain the manual approvalUrl fallback.\n // Read the opener option at start time so a swapped-in custom opener is\n // still honored for desktop flows.\n const approvalWindow =\n browserPlatform === \"desktop\"\n ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)()\n : null;\n openedWindow = approvalWindow;\n\n setState({ type: \"creating\" });\n\n let request: AccessRequest;\n try {\n request = await transports.createRequest();\n } catch (err) {\n // If we were superseded (reset, possibly + a newer start()) while this\n // request was in flight, only clean up our own tab — never the shared\n // state or the newer run's window.\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n running = false;\n closeUnnavigatedWindow();\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n // Re-validate the continuation URL at the SDK boundary (defense in depth\n // for custom transports that bypass the default client).\n request = {\n ...request,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n request.mobileContinuationUrl,\n ),\n };\n\n // The SDK owns only the small mobile-versus-desktop destination choice.\n // A deep Direct request on mobile carries a validated continuation URL;\n // desktop keeps its popup contract, while mobile light exposes the HTTPS\n // approval URL as the existing manual fallback without opening a tab.\n const mobileContinuationUrl =\n browserPlatform === \"mobile\"\n ? request.mobileContinuationUrl\n : undefined;\n\n if (mobileContinuationUrl) {\n // Do not auto-launch: DCR creation is async, so the original Connect\n // gesture can no longer be trusted to retain iOS user activation. Let\n // the UI render an explicit primary \"Open Vana\" link; polling continues\n // in this tab.\n startPolling(request, {\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl,\n });\n return;\n }\n\n // Desktop/light: navigate the synchronously-opened tab to the HTTPS\n // approval URL. `approvalWindow === null` means the popup was blocked;\n // surface it so the UI renders request.approvalUrl as a visible manual\n // \"Open approval\" link instead of hanging. We poll either way, so a manual\n // open still resolves the flow, and the timeout still bounds the wait.\n if (approvalWindow) {\n approvalWindow.navigate(request.approvalUrl);\n // Hand the tab off to the user; we no longer own/close it.\n openedWindow = null;\n }\n startPolling(request, {\n type: \"awaiting_approval\",\n request,\n popupBlocked: approvalWindow === null,\n });\n },\n\n reset(): void {\n running = false;\n // Invalidate any in-flight start() so a late createRequest can't clobber\n // a subsequent run.\n activeRunId++;\n clearPoll();\n closeUnnavigatedWindow();\n setState({ type: \"idle\" });\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/direct/connect-flow.ts"],"sourcesContent":["/**\n * Framework-agnostic connect-flow state machine for the browser two-tab helper.\n *\n * @remarks\n * This is the testable core behind {@link useDirectVanaConnect}. It is pure\n * TypeScript (no React, no DOM-only APIs beyond an injectable window opener and\n * timers) so the full flow — create request, open Vana, poll status, read data —\n * can be exercised in a Node test environment.\n *\n * The React hook is a thin `useSyncExternalStore` binding over this store.\n *\n * @category Direct\n * @module direct/connect-flow\n */\n\nimport type {\n AccessRequest,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\n\n/**\n * Caller-supplied transports. These typically `fetch` the app's own backend\n * routes, which in turn delegate to a {@link DirectDataController}.\n */\nexport interface DirectConnectTransports<T = unknown> {\n /** Ask the backend to create an access request. */\n createRequest: () => Promise<AccessRequest>;\n /** Ask the backend for the current status of a request. */\n getStatus: (requestId: string) => Promise<AccessRequestStatus>;\n /** Ask the backend to read the approved data. */\n readResult: (requestId: string) => Promise<ApprovedDataResult<T>>;\n}\n\n/**\n * A handle to a tab opened synchronously under the user's click gesture.\n *\n * @remarks\n * The flow opens this tab *before* it knows the approval URL (popup blockers\n * only allow `window.open()` during the click's transient activation), then\n * navigates it once `createRequest` resolves.\n */\nexport interface ConnectWindow {\n /** Point the already-open tab at the approval URL. */\n navigate(url: string): void;\n /** Close the tab (used to clean up an un-navigated tab on failure/reset). */\n close(): void;\n}\n\n/** Browser class used only to choose the destination returned by Vana. */\nexport type DirectBrowserPlatform = \"desktop\" | \"mobile\";\n\n/** Injectable browser-platform policy; it never asserts whether an app exists. */\nexport interface DirectBrowserPlatformPolicy {\n current(): DirectBrowserPlatform;\n}\n\n/** Tunables for the connect flow. */\nexport interface DirectConnectOptions {\n /** Status poll interval in ms. Defaults to 1500. */\n pollIntervalMs?: number;\n /**\n * Overall timeout in ms before giving up. Defaults to 300000 (5 min).\n * Used only when the access request does not carry an authoritative\n * `expiresAt` value.\n */\n timeoutMs?: number;\n /**\n * Synchronously open a blank tab under the click's transient activation and\n * return a handle to navigate later, or `null` if the browser blocked it.\n * Defaults to `window.open(\"\", \"_blank\")` (with `opener` severed). Injectable\n * for tests.\n *\n * @remarks\n * Renamed from the pre-3.8 `openWindow?: (url) => void`. The old contract was\n * the BUI-622 bug itself (it was called with the URL *after* an `await`, so\n * the popup blocker suppressed it); it cannot be preserved while fixing the\n * bug. Custom openers must now open synchronously and return a navigable\n * handle.\n */\n openApprovalWindow?: () => ConnectWindow | null;\n /** SDK-owned mobile/desktop policy. Injectable for deterministic tests. */\n browserPlatformPolicy?: DirectBrowserPlatformPolicy;\n /** `setTimeout`. Injectable for tests. Defaults to `globalThis.setTimeout`. */\n setTimeoutFn?: (cb: () => void, ms: number) => unknown;\n /** `clearTimeout`. Injectable for tests. Defaults to `globalThis.clearTimeout`. */\n clearTimeoutFn?: (handle: unknown) => void;\n /** Clock source in ms. Injectable for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Discriminated connect-flow state.\n *\n * @remarks\n * `type` matches the builder guide: it starts at `\"idle\"` and is non-idle while\n * connecting. The intermediate phases give richer UIs something to render.\n *\n * Desktop and light-data requests move through `\"awaiting_approval\"` (Vana Web\n * opens in a popup). A deep Direct request on a mobile browser moves through\n * `\"ready_to_open\"` instead: the SDK exposes a plain HTTPS\n * `mobileContinuationUrl` for the UI to render as a primary \"Open Vana\" link,\n * never launching it automatically, and keeps polling in memory.\n */\nexport type DirectConnectState<T = unknown> =\n | { type: \"idle\" }\n | { type: \"creating\" }\n | {\n type: \"awaiting_approval\";\n request: AccessRequest;\n /**\n * `true` when the popup was blocked. The UI should render the universal\n * HTTPS `request.approvalUrl` as a manual \"Open approval\" link.\n */\n popupBlocked: boolean;\n }\n | {\n type: \"ready_to_open\";\n request: AccessRequest;\n /**\n * Validated HTTPS continuation URL the mobile UI renders as the primary\n * \"Open Vana\" tap. Polling continues while it is shown; its embedded\n * ticket may rotate to a fresh URL between polls.\n */\n mobileContinuationUrl: string;\n }\n | { type: \"reading\"; request: AccessRequest }\n | { type: \"done\"; result: ApprovedDataResult<T> }\n | { type: \"error\"; error: Error };\n\n/** Whether an explicit read retry reused consent or started fresh approval. */\nexport type DirectConnectRetryOutcome =\n | \"retried_existing_grant\"\n | \"fresh_approval_required\";\n\n/** The store returned by {@link createDirectConnectFlow}. */\nexport interface DirectConnectFlow<T = unknown> {\n /** Current state. */\n getState(): DirectConnectState<T>;\n /** Subscribe to state changes; returns an unsubscribe function. */\n subscribe(listener: () => void): () => void;\n /** Begin the flow. No-op if already running. */\n start(): Promise<void>;\n /**\n * Retry a failed read, reusing a still-live approved request when possible.\n *\n * @remarks\n * This explicit path avoids the observed double-approval symptom where\n * \"Try that again\" minted a new request after a transient read failure.\n * The return value tells callers whether existing consent was reused or a\n * fresh approval was required.\n */\n retryRead(): Promise<DirectConnectRetryOutcome>;\n /** Reset to `idle` and stop any in-flight polling. */\n reset(): void;\n}\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_TIMEOUT_MS = 300_000;\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\nconst MOBILE_USER_AGENT =\n /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle|Opera Mini|IEMobile/i;\n\nfunction defaultBrowserPlatformPolicy(): DirectBrowserPlatformPolicy {\n return {\n current() {\n if (typeof navigator === \"undefined\") return \"desktop\";\n const isTouchCapableIpad =\n navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1;\n return MOBILE_USER_AGENT.test(navigator.userAgent) || isTouchCapableIpad\n ? \"mobile\"\n : \"desktop\";\n },\n };\n}\n\n/**\n * Default {@link DirectConnectOptions.openApprovalWindow}: open a blank tab\n * synchronously (inside the click gesture) and return a handle to navigate\n * once the approval URL is known. Returns `null` when blocked or non-DOM.\n */\nfunction defaultOpenApprovalWindow(): ConnectWindow | null {\n if (typeof window === \"undefined\" || !window.open) return null;\n // We can't pass the \"noopener\"/\"noreferrer\" feature string here: it makes\n // window.open() return null, which would throw away the handle we need to\n // navigate later. So we open plain and re-create both protections by hand.\n const opened = window.open(\"\", \"_blank\");\n if (!opened) return null;\n // Sever the opener link while the tab is still about:blank, so the approval\n // page can't reach back into the app (reverse tab-nabbing).\n try {\n opened.opener = null;\n } catch {\n // Some environments make `opener` read-only; best-effort only.\n }\n return {\n navigate(url: string) {\n // Restore the no-referrer protection the old \"noreferrer\" feature gave:\n // tag the blank document so the upcoming navigation sends no Referer to\n // the approval page (best-effort; the blank doc is same-origin here).\n try {\n const meta = opened.document.createElement(\"meta\");\n meta.name = \"referrer\";\n meta.content = \"no-referrer\";\n (opened.document.head ?? opened.document.documentElement)?.appendChild(\n meta,\n );\n } catch {\n // Cross-origin/unavailable document: skip, navigation still proceeds.\n }\n opened.location.href = url;\n },\n close() {\n opened.close();\n },\n };\n}\n\n/**\n * Create a connect-flow store.\n *\n * @param transports - Backend transports (`createRequest`, `getStatus`, `readResult`).\n * @param options - Polling/timeout tunables and injectable side effects.\n * @returns A {@link DirectConnectFlow} store.\n */\nexport function createDirectConnectFlow<T = unknown>(\n transports: DirectConnectTransports<T>,\n options: DirectConnectOptions = {},\n): DirectConnectFlow<T> {\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n // `openApprovalWindow` and `browserPlatformPolicy` are resolved lazily at\n // start() (see below) so options swapped in after construction are still\n // honoured — matching the latest-callback pattern the React hook uses for its\n // transports.\n const setTimeoutFn =\n options.setTimeoutFn ??\n ((cb: () => void, ms: number) => globalThis.setTimeout(cb, ms));\n const clearTimeoutFn =\n options.clearTimeoutFn ??\n ((handle: unknown) => {\n globalThis.clearTimeout(handle as never);\n });\n const now = options.now ?? (() => Date.now());\n let state: DirectConnectState<T> = { type: \"idle\" };\n const listeners = new Set<() => void>();\n let pollHandle: unknown = null;\n let running = false;\n // Retained only after status proved this request had a read-ready grant.\n // A retry rechecks that status before trusting the prior consent.\n let approvedRequest: AccessRequest | null = null;\n // Monotonic id for the current start() invocation. reset() (and an\n // immediately following start()) bumps it, so a previous run whose async\n // createRequest is still in flight can detect it has been superseded and\n // avoid touching shared state / the newer run's tab.\n let activeRunId = 0;\n // Holds the tab we opened only while it is still blank (un-navigated). Once\n // navigated to the approval URL we drop the reference so reset/cleanup never\n // closes the live approval tab the user is interacting with.\n let openedWindow: ConnectWindow | null = null;\n\n function emit(): void {\n for (const listener of listeners) listener();\n }\n\n function setState(next: DirectConnectState<T>): void {\n state = next;\n emit();\n }\n\n function clearPoll(): void {\n if (pollHandle !== null) {\n clearTimeoutFn(pollHandle);\n pollHandle = null;\n }\n }\n\n /** Close the opened tab if it is still blank (never navigated). */\n function closeUnnavigatedWindow(): void {\n if (openedWindow) {\n openedWindow.close();\n openedWindow = null;\n }\n }\n\n function isRunningPhase(): boolean {\n return (\n state.type === \"creating\" ||\n state.type === \"awaiting_approval\" ||\n state.type === \"ready_to_open\" ||\n state.type === \"reading\"\n );\n }\n\n async function readAndFinish(request: AccessRequest): Promise<void> {\n setState({ type: \"reading\", request });\n try {\n const result = await transports.readResult(request.requestId);\n if (!running) return;\n setState({ type: \"done\", result });\n } catch (err) {\n if (!running) return;\n setState({ type: \"error\", error: toError(err) });\n } finally {\n running = false;\n }\n }\n\n function scheduleNextPoll(request: AccessRequest, deadline: number): void {\n pollHandle = setTimeoutFn(() => {\n void poll(request, deadline);\n }, pollIntervalMs);\n }\n\n function requestDeadline(request: AccessRequest): number {\n if (request.expiresAt !== undefined) {\n const expiresAt = Date.parse(request.expiresAt);\n if (Number.isFinite(expiresAt)) return expiresAt;\n }\n return now() + timeoutMs;\n }\n\n /**\n * Enter the polling loop from the given initial state (either\n * `awaiting_approval` for desktop/light or `ready_to_open` for mobile-deep).\n * Errors out immediately if the request has already expired.\n */\n function startPolling(\n request: AccessRequest,\n initialState: DirectConnectState<T>,\n ): void {\n setState(initialState);\n const deadline = requestDeadline(request);\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Access request expired\"),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n async function poll(request: AccessRequest, deadline: number): Promise<void> {\n if (!running) return;\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Timed out waiting for approval\"),\n });\n return;\n }\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (!running) return;\n running = false;\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (!running) return;\n\n // A pending deep-mobile status may rotate the continuation ticket. Adopt a\n // fresh, still-valid URL so the rendered \"Open Vana\" link always points at a\n // live ticket; ignore it on the desktop/light path.\n if (status.status === \"pending\" && state.type === \"ready_to_open\") {\n const refreshed = normalizeMobileContinuationUrl(\n status.mobileContinuationUrl,\n );\n if (refreshed && refreshed !== state.mobileContinuationUrl) {\n request = { ...request, mobileContinuationUrl: refreshed };\n setState({\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl: refreshed,\n });\n }\n }\n\n if (isReadReadyStatus(status.status)) {\n clearPoll();\n approvedRequest = request;\n await readAndFinish(request);\n return;\n }\n if (\n status.status === \"completed\" ||\n status.status === \"denied\" ||\n status.status === \"expired\"\n ) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(`Access request ${status.status}`),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n const flow: DirectConnectFlow<T> = {\n getState() {\n return state;\n },\n\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n\n async start(): Promise<void> {\n if (running || isRunningPhase()) return;\n running = true;\n // start() deliberately keeps its established first-run semantics: every\n // explicit start creates a request. Only retryRead() may reuse consent.\n approvedRequest = null;\n const runId = ++activeRunId;\n // Read the platform policy at start time, like openApprovalWindow below,\n // so a policy swapped in after construction (a React rerender forwards\n // options through a ref) still decides this run's destination.\n const browserPlatform = (\n options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()\n ).current();\n\n // Desktop preserves the pre-mobile synchronous popup contract: open a\n // blank tab while the click's transient activation is live, then navigate\n // it once createRequest returns the approval URL (BUI-622). Mobile never\n // creates that transient tab; deep requests expose one explicit HTTPS\n // link, while light requests retain the manual approvalUrl fallback.\n // Read the opener option at start time so a swapped-in custom opener is\n // still honored for desktop flows.\n const approvalWindow =\n browserPlatform === \"desktop\"\n ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)()\n : null;\n openedWindow = approvalWindow;\n\n setState({ type: \"creating\" });\n\n let request: AccessRequest;\n try {\n request = await transports.createRequest();\n } catch (err) {\n // If we were superseded (reset, possibly + a newer start()) while this\n // request was in flight, only clean up our own tab — never the shared\n // state or the newer run's window.\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n running = false;\n closeUnnavigatedWindow();\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n // Re-validate the continuation URL at the SDK boundary (defense in depth\n // for custom transports that bypass the default client).\n request = {\n ...request,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n request.mobileContinuationUrl,\n ),\n };\n\n // The SDK owns only the small mobile-versus-desktop destination choice.\n // A deep Direct request on mobile carries a validated continuation URL;\n // desktop keeps its popup contract, while mobile light exposes the HTTPS\n // approval URL as the existing manual fallback without opening a tab.\n const mobileContinuationUrl =\n browserPlatform === \"mobile\"\n ? request.mobileContinuationUrl\n : undefined;\n\n if (mobileContinuationUrl) {\n // Do not auto-launch: DCR creation is async, so the original Connect\n // gesture can no longer be trusted to retain iOS user activation. Let\n // the UI render an explicit primary \"Open Vana\" link; polling continues\n // in this tab.\n startPolling(request, {\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl,\n });\n return;\n }\n\n // Desktop/light: navigate the synchronously-opened tab to the HTTPS\n // approval URL. `approvalWindow === null` means the popup was blocked;\n // surface it so the UI renders request.approvalUrl as a visible manual\n // \"Open approval\" link instead of hanging. We poll either way, so a manual\n // open still resolves the flow, and the timeout still bounds the wait.\n if (approvalWindow) {\n approvalWindow.navigate(request.approvalUrl);\n // Hand the tab off to the user; we no longer own/close it.\n openedWindow = null;\n }\n startPolling(request, {\n type: \"awaiting_approval\",\n request,\n popupBlocked: approvalWindow === null,\n });\n },\n\n async retryRead(): Promise<DirectConnectRetryOutcome> {\n if (running || isRunningPhase()) {\n throw new Error(\n \"Cannot retry a read while the connect flow is running\",\n );\n }\n\n const request = approvedRequest;\n const parsedExpiry = request?.expiresAt\n ? Date.parse(request.expiresAt)\n : Number.NaN;\n const requestExpired =\n Number.isFinite(parsedExpiry) && now() >= parsedExpiry;\n\n if (request && !requestExpired) {\n running = true;\n const runId = ++activeRunId;\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (runId !== activeRunId) {\n throw new Error(\"Read retry was superseded\");\n }\n running = false;\n const error = toError(err);\n setState({ type: \"error\", error });\n throw error;\n }\n if (runId !== activeRunId) {\n throw new Error(\"Read retry was superseded\");\n }\n if (isReadReadyStatus(status.status)) {\n await readAndFinish(request);\n return \"retried_existing_grant\";\n }\n running = false;\n }\n\n approvedRequest = null;\n await flow.start();\n return \"fresh_approval_required\";\n },\n\n reset(): void {\n running = false;\n approvedRequest = null;\n // Invalidate any in-flight start() so a late createRequest can't clobber\n // a subsequent run.\n activeRunId++;\n clearPoll();\n closeUnnavigatedWindow();\n setState({ type: \"idle\" });\n },\n };\n\n return flow;\n}\n"],"mappings":"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","names":[]}
@@ -122,6 +122,8 @@ export type DirectConnectState<T = unknown> = {
122
122
  type: "error";
123
123
  error: Error;
124
124
  };
125
+ /** Whether an explicit read retry reused consent or started fresh approval. */
126
+ export type DirectConnectRetryOutcome = "retried_existing_grant" | "fresh_approval_required";
125
127
  /** The store returned by {@link createDirectConnectFlow}. */
126
128
  export interface DirectConnectFlow<T = unknown> {
127
129
  /** Current state. */
@@ -130,6 +132,16 @@ export interface DirectConnectFlow<T = unknown> {
130
132
  subscribe(listener: () => void): () => void;
131
133
  /** Begin the flow. No-op if already running. */
132
134
  start(): Promise<void>;
135
+ /**
136
+ * Retry a failed read, reusing a still-live approved request when possible.
137
+ *
138
+ * @remarks
139
+ * This explicit path avoids the observed double-approval symptom where
140
+ * "Try that again" minted a new request after a transient read failure.
141
+ * The return value tells callers whether existing consent was reused or a
142
+ * fresh approval was required.
143
+ */
144
+ retryRead(): Promise<DirectConnectRetryOutcome>;
133
145
  /** Reset to `idle` and stop any in-flight polling. */
134
146
  reset(): void;
135
147
  }
@@ -55,6 +55,7 @@ function createDirectConnectFlow(transports, options = {}) {
55
55
  const listeners = /* @__PURE__ */ new Set();
56
56
  let pollHandle = null;
57
57
  let running = false;
58
+ let approvedRequest = null;
58
59
  let activeRunId = 0;
59
60
  let openedWindow = null;
60
61
  function emit() {
@@ -152,6 +153,7 @@ function createDirectConnectFlow(transports, options = {}) {
152
153
  }
153
154
  if (isReadReadyStatus(status.status)) {
154
155
  clearPoll();
156
+ approvedRequest = request;
155
157
  await readAndFinish(request);
156
158
  return;
157
159
  }
@@ -165,7 +167,7 @@ function createDirectConnectFlow(transports, options = {}) {
165
167
  }
166
168
  scheduleNextPoll(request, deadline);
167
169
  }
168
- return {
170
+ const flow = {
169
171
  getState() {
170
172
  return state;
171
173
  },
@@ -176,6 +178,7 @@ function createDirectConnectFlow(transports, options = {}) {
176
178
  async start() {
177
179
  if (running || isRunningPhase()) return;
178
180
  running = true;
181
+ approvedRequest = null;
179
182
  const runId = ++activeRunId;
180
183
  const browserPlatform = (options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()).current();
181
184
  const approvalWindow = browserPlatform === "desktop" ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)() : null;
@@ -223,14 +226,53 @@ function createDirectConnectFlow(transports, options = {}) {
223
226
  popupBlocked: approvalWindow === null
224
227
  });
225
228
  },
229
+ async retryRead() {
230
+ if (running || isRunningPhase()) {
231
+ throw new Error(
232
+ "Cannot retry a read while the connect flow is running"
233
+ );
234
+ }
235
+ const request = approvedRequest;
236
+ const parsedExpiry = request?.expiresAt ? Date.parse(request.expiresAt) : Number.NaN;
237
+ const requestExpired = Number.isFinite(parsedExpiry) && now() >= parsedExpiry;
238
+ if (request && !requestExpired) {
239
+ running = true;
240
+ const runId = ++activeRunId;
241
+ let status;
242
+ try {
243
+ status = await transports.getStatus(request.requestId);
244
+ } catch (err) {
245
+ if (runId !== activeRunId) {
246
+ throw new Error("Read retry was superseded");
247
+ }
248
+ running = false;
249
+ const error = toError(err);
250
+ setState({ type: "error", error });
251
+ throw error;
252
+ }
253
+ if (runId !== activeRunId) {
254
+ throw new Error("Read retry was superseded");
255
+ }
256
+ if (isReadReadyStatus(status.status)) {
257
+ await readAndFinish(request);
258
+ return "retried_existing_grant";
259
+ }
260
+ running = false;
261
+ }
262
+ approvedRequest = null;
263
+ await flow.start();
264
+ return "fresh_approval_required";
265
+ },
226
266
  reset() {
227
267
  running = false;
268
+ approvedRequest = null;
228
269
  activeRunId++;
229
270
  clearPoll();
230
271
  closeUnnavigatedWindow();
231
272
  setState({ type: "idle" });
232
273
  }
233
274
  };
275
+ return flow;
234
276
  }
235
277
  export {
236
278
  createDirectConnectFlow
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/direct/connect-flow.ts"],"sourcesContent":["/**\n * Framework-agnostic connect-flow state machine for the browser two-tab helper.\n *\n * @remarks\n * This is the testable core behind {@link useDirectVanaConnect}. It is pure\n * TypeScript (no React, no DOM-only APIs beyond an injectable window opener and\n * timers) so the full flow — create request, open Vana, poll status, read data —\n * can be exercised in a Node test environment.\n *\n * The React hook is a thin `useSyncExternalStore` binding over this store.\n *\n * @category Direct\n * @module direct/connect-flow\n */\n\nimport type {\n AccessRequest,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\n\n/**\n * Caller-supplied transports. These typically `fetch` the app's own backend\n * routes, which in turn delegate to a {@link DirectDataController}.\n */\nexport interface DirectConnectTransports<T = unknown> {\n /** Ask the backend to create an access request. */\n createRequest: () => Promise<AccessRequest>;\n /** Ask the backend for the current status of a request. */\n getStatus: (requestId: string) => Promise<AccessRequestStatus>;\n /** Ask the backend to read the approved data. */\n readResult: (requestId: string) => Promise<ApprovedDataResult<T>>;\n}\n\n/**\n * A handle to a tab opened synchronously under the user's click gesture.\n *\n * @remarks\n * The flow opens this tab *before* it knows the approval URL (popup blockers\n * only allow `window.open()` during the click's transient activation), then\n * navigates it once `createRequest` resolves.\n */\nexport interface ConnectWindow {\n /** Point the already-open tab at the approval URL. */\n navigate(url: string): void;\n /** Close the tab (used to clean up an un-navigated tab on failure/reset). */\n close(): void;\n}\n\n/** Browser class used only to choose the destination returned by Vana. */\nexport type DirectBrowserPlatform = \"desktop\" | \"mobile\";\n\n/** Injectable browser-platform policy; it never asserts whether an app exists. */\nexport interface DirectBrowserPlatformPolicy {\n current(): DirectBrowserPlatform;\n}\n\n/** Tunables for the connect flow. */\nexport interface DirectConnectOptions {\n /** Status poll interval in ms. Defaults to 1500. */\n pollIntervalMs?: number;\n /**\n * Overall timeout in ms before giving up. Defaults to 300000 (5 min).\n * Used only when the access request does not carry an authoritative\n * `expiresAt` value.\n */\n timeoutMs?: number;\n /**\n * Synchronously open a blank tab under the click's transient activation and\n * return a handle to navigate later, or `null` if the browser blocked it.\n * Defaults to `window.open(\"\", \"_blank\")` (with `opener` severed). Injectable\n * for tests.\n *\n * @remarks\n * Renamed from the pre-3.8 `openWindow?: (url) => void`. The old contract was\n * the BUI-622 bug itself (it was called with the URL *after* an `await`, so\n * the popup blocker suppressed it); it cannot be preserved while fixing the\n * bug. Custom openers must now open synchronously and return a navigable\n * handle.\n */\n openApprovalWindow?: () => ConnectWindow | null;\n /** SDK-owned mobile/desktop policy. Injectable for deterministic tests. */\n browserPlatformPolicy?: DirectBrowserPlatformPolicy;\n /** `setTimeout`. Injectable for tests. Defaults to `globalThis.setTimeout`. */\n setTimeoutFn?: (cb: () => void, ms: number) => unknown;\n /** `clearTimeout`. Injectable for tests. Defaults to `globalThis.clearTimeout`. */\n clearTimeoutFn?: (handle: unknown) => void;\n /** Clock source in ms. Injectable for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Discriminated connect-flow state.\n *\n * @remarks\n * `type` matches the builder guide: it starts at `\"idle\"` and is non-idle while\n * connecting. The intermediate phases give richer UIs something to render.\n *\n * Desktop and light-data requests move through `\"awaiting_approval\"` (Vana Web\n * opens in a popup). A deep Direct request on a mobile browser moves through\n * `\"ready_to_open\"` instead: the SDK exposes a plain HTTPS\n * `mobileContinuationUrl` for the UI to render as a primary \"Open Vana\" link,\n * never launching it automatically, and keeps polling in memory.\n */\nexport type DirectConnectState<T = unknown> =\n | { type: \"idle\" }\n | { type: \"creating\" }\n | {\n type: \"awaiting_approval\";\n request: AccessRequest;\n /**\n * `true` when the popup was blocked. The UI should render the universal\n * HTTPS `request.approvalUrl` as a manual \"Open approval\" link.\n */\n popupBlocked: boolean;\n }\n | {\n type: \"ready_to_open\";\n request: AccessRequest;\n /**\n * Validated HTTPS continuation URL the mobile UI renders as the primary\n * \"Open Vana\" tap. Polling continues while it is shown; its embedded\n * ticket may rotate to a fresh URL between polls.\n */\n mobileContinuationUrl: string;\n }\n | { type: \"reading\"; request: AccessRequest }\n | { type: \"done\"; result: ApprovedDataResult<T> }\n | { type: \"error\"; error: Error };\n\n/** The store returned by {@link createDirectConnectFlow}. */\nexport interface DirectConnectFlow<T = unknown> {\n /** Current state. */\n getState(): DirectConnectState<T>;\n /** Subscribe to state changes; returns an unsubscribe function. */\n subscribe(listener: () => void): () => void;\n /** Begin the flow. No-op if already running. */\n start(): Promise<void>;\n /** Reset to `idle` and stop any in-flight polling. */\n reset(): void;\n}\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_TIMEOUT_MS = 300_000;\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\nconst MOBILE_USER_AGENT =\n /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle|Opera Mini|IEMobile/i;\n\nfunction defaultBrowserPlatformPolicy(): DirectBrowserPlatformPolicy {\n return {\n current() {\n if (typeof navigator === \"undefined\") return \"desktop\";\n const isTouchCapableIpad =\n navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1;\n return MOBILE_USER_AGENT.test(navigator.userAgent) || isTouchCapableIpad\n ? \"mobile\"\n : \"desktop\";\n },\n };\n}\n\n/**\n * Default {@link DirectConnectOptions.openApprovalWindow}: open a blank tab\n * synchronously (inside the click gesture) and return a handle to navigate\n * once the approval URL is known. Returns `null` when blocked or non-DOM.\n */\nfunction defaultOpenApprovalWindow(): ConnectWindow | null {\n if (typeof window === \"undefined\" || !window.open) return null;\n // We can't pass the \"noopener\"/\"noreferrer\" feature string here: it makes\n // window.open() return null, which would throw away the handle we need to\n // navigate later. So we open plain and re-create both protections by hand.\n const opened = window.open(\"\", \"_blank\");\n if (!opened) return null;\n // Sever the opener link while the tab is still about:blank, so the approval\n // page can't reach back into the app (reverse tab-nabbing).\n try {\n opened.opener = null;\n } catch {\n // Some environments make `opener` read-only; best-effort only.\n }\n return {\n navigate(url: string) {\n // Restore the no-referrer protection the old \"noreferrer\" feature gave:\n // tag the blank document so the upcoming navigation sends no Referer to\n // the approval page (best-effort; the blank doc is same-origin here).\n try {\n const meta = opened.document.createElement(\"meta\");\n meta.name = \"referrer\";\n meta.content = \"no-referrer\";\n (opened.document.head ?? opened.document.documentElement)?.appendChild(\n meta,\n );\n } catch {\n // Cross-origin/unavailable document: skip, navigation still proceeds.\n }\n opened.location.href = url;\n },\n close() {\n opened.close();\n },\n };\n}\n\n/**\n * Create a connect-flow store.\n *\n * @param transports - Backend transports (`createRequest`, `getStatus`, `readResult`).\n * @param options - Polling/timeout tunables and injectable side effects.\n * @returns A {@link DirectConnectFlow} store.\n */\nexport function createDirectConnectFlow<T = unknown>(\n transports: DirectConnectTransports<T>,\n options: DirectConnectOptions = {},\n): DirectConnectFlow<T> {\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n // `openApprovalWindow` and `browserPlatformPolicy` are resolved lazily at\n // start() (see below) so options swapped in after construction are still\n // honoured — matching the latest-callback pattern the React hook uses for its\n // transports.\n const setTimeoutFn =\n options.setTimeoutFn ??\n ((cb: () => void, ms: number) => globalThis.setTimeout(cb, ms));\n const clearTimeoutFn =\n options.clearTimeoutFn ??\n ((handle: unknown) => {\n globalThis.clearTimeout(handle as never);\n });\n const now = options.now ?? (() => Date.now());\n let state: DirectConnectState<T> = { type: \"idle\" };\n const listeners = new Set<() => void>();\n let pollHandle: unknown = null;\n let running = false;\n // Monotonic id for the current start() invocation. reset() (and an\n // immediately following start()) bumps it, so a previous run whose async\n // createRequest is still in flight can detect it has been superseded and\n // avoid touching shared state / the newer run's tab.\n let activeRunId = 0;\n // Holds the tab we opened only while it is still blank (un-navigated). Once\n // navigated to the approval URL we drop the reference so reset/cleanup never\n // closes the live approval tab the user is interacting with.\n let openedWindow: ConnectWindow | null = null;\n\n function emit(): void {\n for (const listener of listeners) listener();\n }\n\n function setState(next: DirectConnectState<T>): void {\n state = next;\n emit();\n }\n\n function clearPoll(): void {\n if (pollHandle !== null) {\n clearTimeoutFn(pollHandle);\n pollHandle = null;\n }\n }\n\n /** Close the opened tab if it is still blank (never navigated). */\n function closeUnnavigatedWindow(): void {\n if (openedWindow) {\n openedWindow.close();\n openedWindow = null;\n }\n }\n\n function isRunningPhase(): boolean {\n return (\n state.type === \"creating\" ||\n state.type === \"awaiting_approval\" ||\n state.type === \"ready_to_open\" ||\n state.type === \"reading\"\n );\n }\n\n async function readAndFinish(request: AccessRequest): Promise<void> {\n setState({ type: \"reading\", request });\n try {\n const result = await transports.readResult(request.requestId);\n if (!running) return;\n setState({ type: \"done\", result });\n } catch (err) {\n if (!running) return;\n setState({ type: \"error\", error: toError(err) });\n } finally {\n running = false;\n }\n }\n\n function scheduleNextPoll(request: AccessRequest, deadline: number): void {\n pollHandle = setTimeoutFn(() => {\n void poll(request, deadline);\n }, pollIntervalMs);\n }\n\n function requestDeadline(request: AccessRequest): number {\n if (request.expiresAt !== undefined) {\n const expiresAt = Date.parse(request.expiresAt);\n if (Number.isFinite(expiresAt)) return expiresAt;\n }\n return now() + timeoutMs;\n }\n\n /**\n * Enter the polling loop from the given initial state (either\n * `awaiting_approval` for desktop/light or `ready_to_open` for mobile-deep).\n * Errors out immediately if the request has already expired.\n */\n function startPolling(\n request: AccessRequest,\n initialState: DirectConnectState<T>,\n ): void {\n setState(initialState);\n const deadline = requestDeadline(request);\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Access request expired\"),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n async function poll(request: AccessRequest, deadline: number): Promise<void> {\n if (!running) return;\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Timed out waiting for approval\"),\n });\n return;\n }\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (!running) return;\n running = false;\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (!running) return;\n\n // A pending deep-mobile status may rotate the continuation ticket. Adopt a\n // fresh, still-valid URL so the rendered \"Open Vana\" link always points at a\n // live ticket; ignore it on the desktop/light path.\n if (status.status === \"pending\" && state.type === \"ready_to_open\") {\n const refreshed = normalizeMobileContinuationUrl(\n status.mobileContinuationUrl,\n );\n if (refreshed && refreshed !== state.mobileContinuationUrl) {\n request = { ...request, mobileContinuationUrl: refreshed };\n setState({\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl: refreshed,\n });\n }\n }\n\n if (isReadReadyStatus(status.status)) {\n clearPoll();\n await readAndFinish(request);\n return;\n }\n if (\n status.status === \"completed\" ||\n status.status === \"denied\" ||\n status.status === \"expired\"\n ) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(`Access request ${status.status}`),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n return {\n getState() {\n return state;\n },\n\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n\n async start(): Promise<void> {\n if (running || isRunningPhase()) return;\n running = true;\n const runId = ++activeRunId;\n // Read the platform policy at start time, like openApprovalWindow below,\n // so a policy swapped in after construction (a React rerender forwards\n // options through a ref) still decides this run's destination.\n const browserPlatform = (\n options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()\n ).current();\n\n // Desktop preserves the pre-mobile synchronous popup contract: open a\n // blank tab while the click's transient activation is live, then navigate\n // it once createRequest returns the approval URL (BUI-622). Mobile never\n // creates that transient tab; deep requests expose one explicit HTTPS\n // link, while light requests retain the manual approvalUrl fallback.\n // Read the opener option at start time so a swapped-in custom opener is\n // still honored for desktop flows.\n const approvalWindow =\n browserPlatform === \"desktop\"\n ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)()\n : null;\n openedWindow = approvalWindow;\n\n setState({ type: \"creating\" });\n\n let request: AccessRequest;\n try {\n request = await transports.createRequest();\n } catch (err) {\n // If we were superseded (reset, possibly + a newer start()) while this\n // request was in flight, only clean up our own tab — never the shared\n // state or the newer run's window.\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n running = false;\n closeUnnavigatedWindow();\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n // Re-validate the continuation URL at the SDK boundary (defense in depth\n // for custom transports that bypass the default client).\n request = {\n ...request,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n request.mobileContinuationUrl,\n ),\n };\n\n // The SDK owns only the small mobile-versus-desktop destination choice.\n // A deep Direct request on mobile carries a validated continuation URL;\n // desktop keeps its popup contract, while mobile light exposes the HTTPS\n // approval URL as the existing manual fallback without opening a tab.\n const mobileContinuationUrl =\n browserPlatform === \"mobile\"\n ? request.mobileContinuationUrl\n : undefined;\n\n if (mobileContinuationUrl) {\n // Do not auto-launch: DCR creation is async, so the original Connect\n // gesture can no longer be trusted to retain iOS user activation. Let\n // the UI render an explicit primary \"Open Vana\" link; polling continues\n // in this tab.\n startPolling(request, {\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl,\n });\n return;\n }\n\n // Desktop/light: navigate the synchronously-opened tab to the HTTPS\n // approval URL. `approvalWindow === null` means the popup was blocked;\n // surface it so the UI renders request.approvalUrl as a visible manual\n // \"Open approval\" link instead of hanging. We poll either way, so a manual\n // open still resolves the flow, and the timeout still bounds the wait.\n if (approvalWindow) {\n approvalWindow.navigate(request.approvalUrl);\n // Hand the tab off to the user; we no longer own/close it.\n openedWindow = null;\n }\n startPolling(request, {\n type: \"awaiting_approval\",\n request,\n popupBlocked: approvalWindow === null,\n });\n },\n\n reset(): void {\n running = false;\n // Invalidate any in-flight start() so a late createRequest can't clobber\n // a subsequent run.\n activeRunId++;\n clearPoll();\n closeUnnavigatedWindow();\n setState({ type: \"idle\" });\n },\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/direct/connect-flow.ts"],"sourcesContent":["/**\n * Framework-agnostic connect-flow state machine for the browser two-tab helper.\n *\n * @remarks\n * This is the testable core behind {@link useDirectVanaConnect}. It is pure\n * TypeScript (no React, no DOM-only APIs beyond an injectable window opener and\n * timers) so the full flow — create request, open Vana, poll status, read data —\n * can be exercised in a Node test environment.\n *\n * The React hook is a thin `useSyncExternalStore` binding over this store.\n *\n * @category Direct\n * @module direct/connect-flow\n */\n\nimport type {\n AccessRequest,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n} from \"./types\";\nimport { normalizeMobileContinuationUrl } from \"./types\";\n\n/**\n * Caller-supplied transports. These typically `fetch` the app's own backend\n * routes, which in turn delegate to a {@link DirectDataController}.\n */\nexport interface DirectConnectTransports<T = unknown> {\n /** Ask the backend to create an access request. */\n createRequest: () => Promise<AccessRequest>;\n /** Ask the backend for the current status of a request. */\n getStatus: (requestId: string) => Promise<AccessRequestStatus>;\n /** Ask the backend to read the approved data. */\n readResult: (requestId: string) => Promise<ApprovedDataResult<T>>;\n}\n\n/**\n * A handle to a tab opened synchronously under the user's click gesture.\n *\n * @remarks\n * The flow opens this tab *before* it knows the approval URL (popup blockers\n * only allow `window.open()` during the click's transient activation), then\n * navigates it once `createRequest` resolves.\n */\nexport interface ConnectWindow {\n /** Point the already-open tab at the approval URL. */\n navigate(url: string): void;\n /** Close the tab (used to clean up an un-navigated tab on failure/reset). */\n close(): void;\n}\n\n/** Browser class used only to choose the destination returned by Vana. */\nexport type DirectBrowserPlatform = \"desktop\" | \"mobile\";\n\n/** Injectable browser-platform policy; it never asserts whether an app exists. */\nexport interface DirectBrowserPlatformPolicy {\n current(): DirectBrowserPlatform;\n}\n\n/** Tunables for the connect flow. */\nexport interface DirectConnectOptions {\n /** Status poll interval in ms. Defaults to 1500. */\n pollIntervalMs?: number;\n /**\n * Overall timeout in ms before giving up. Defaults to 300000 (5 min).\n * Used only when the access request does not carry an authoritative\n * `expiresAt` value.\n */\n timeoutMs?: number;\n /**\n * Synchronously open a blank tab under the click's transient activation and\n * return a handle to navigate later, or `null` if the browser blocked it.\n * Defaults to `window.open(\"\", \"_blank\")` (with `opener` severed). Injectable\n * for tests.\n *\n * @remarks\n * Renamed from the pre-3.8 `openWindow?: (url) => void`. The old contract was\n * the BUI-622 bug itself (it was called with the URL *after* an `await`, so\n * the popup blocker suppressed it); it cannot be preserved while fixing the\n * bug. Custom openers must now open synchronously and return a navigable\n * handle.\n */\n openApprovalWindow?: () => ConnectWindow | null;\n /** SDK-owned mobile/desktop policy. Injectable for deterministic tests. */\n browserPlatformPolicy?: DirectBrowserPlatformPolicy;\n /** `setTimeout`. Injectable for tests. Defaults to `globalThis.setTimeout`. */\n setTimeoutFn?: (cb: () => void, ms: number) => unknown;\n /** `clearTimeout`. Injectable for tests. Defaults to `globalThis.clearTimeout`. */\n clearTimeoutFn?: (handle: unknown) => void;\n /** Clock source in ms. Injectable for tests. Defaults to `Date.now`. */\n now?: () => number;\n}\n\n/**\n * Discriminated connect-flow state.\n *\n * @remarks\n * `type` matches the builder guide: it starts at `\"idle\"` and is non-idle while\n * connecting. The intermediate phases give richer UIs something to render.\n *\n * Desktop and light-data requests move through `\"awaiting_approval\"` (Vana Web\n * opens in a popup). A deep Direct request on a mobile browser moves through\n * `\"ready_to_open\"` instead: the SDK exposes a plain HTTPS\n * `mobileContinuationUrl` for the UI to render as a primary \"Open Vana\" link,\n * never launching it automatically, and keeps polling in memory.\n */\nexport type DirectConnectState<T = unknown> =\n | { type: \"idle\" }\n | { type: \"creating\" }\n | {\n type: \"awaiting_approval\";\n request: AccessRequest;\n /**\n * `true` when the popup was blocked. The UI should render the universal\n * HTTPS `request.approvalUrl` as a manual \"Open approval\" link.\n */\n popupBlocked: boolean;\n }\n | {\n type: \"ready_to_open\";\n request: AccessRequest;\n /**\n * Validated HTTPS continuation URL the mobile UI renders as the primary\n * \"Open Vana\" tap. Polling continues while it is shown; its embedded\n * ticket may rotate to a fresh URL between polls.\n */\n mobileContinuationUrl: string;\n }\n | { type: \"reading\"; request: AccessRequest }\n | { type: \"done\"; result: ApprovedDataResult<T> }\n | { type: \"error\"; error: Error };\n\n/** Whether an explicit read retry reused consent or started fresh approval. */\nexport type DirectConnectRetryOutcome =\n | \"retried_existing_grant\"\n | \"fresh_approval_required\";\n\n/** The store returned by {@link createDirectConnectFlow}. */\nexport interface DirectConnectFlow<T = unknown> {\n /** Current state. */\n getState(): DirectConnectState<T>;\n /** Subscribe to state changes; returns an unsubscribe function. */\n subscribe(listener: () => void): () => void;\n /** Begin the flow. No-op if already running. */\n start(): Promise<void>;\n /**\n * Retry a failed read, reusing a still-live approved request when possible.\n *\n * @remarks\n * This explicit path avoids the observed double-approval symptom where\n * \"Try that again\" minted a new request after a transient read failure.\n * The return value tells callers whether existing consent was reused or a\n * fresh approval was required.\n */\n retryRead(): Promise<DirectConnectRetryOutcome>;\n /** Reset to `idle` and stop any in-flight polling. */\n reset(): void;\n}\n\nconst DEFAULT_POLL_INTERVAL_MS = 1500;\nconst DEFAULT_TIMEOUT_MS = 300_000;\n\nfunction toError(value: unknown): Error {\n return value instanceof Error ? value : new Error(String(value));\n}\n\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\nconst MOBILE_USER_AGENT =\n /Android|iPhone|iPad|iPod|Mobile|Silk|Kindle|Opera Mini|IEMobile/i;\n\nfunction defaultBrowserPlatformPolicy(): DirectBrowserPlatformPolicy {\n return {\n current() {\n if (typeof navigator === \"undefined\") return \"desktop\";\n const isTouchCapableIpad =\n navigator.platform === \"MacIntel\" && navigator.maxTouchPoints > 1;\n return MOBILE_USER_AGENT.test(navigator.userAgent) || isTouchCapableIpad\n ? \"mobile\"\n : \"desktop\";\n },\n };\n}\n\n/**\n * Default {@link DirectConnectOptions.openApprovalWindow}: open a blank tab\n * synchronously (inside the click gesture) and return a handle to navigate\n * once the approval URL is known. Returns `null` when blocked or non-DOM.\n */\nfunction defaultOpenApprovalWindow(): ConnectWindow | null {\n if (typeof window === \"undefined\" || !window.open) return null;\n // We can't pass the \"noopener\"/\"noreferrer\" feature string here: it makes\n // window.open() return null, which would throw away the handle we need to\n // navigate later. So we open plain and re-create both protections by hand.\n const opened = window.open(\"\", \"_blank\");\n if (!opened) return null;\n // Sever the opener link while the tab is still about:blank, so the approval\n // page can't reach back into the app (reverse tab-nabbing).\n try {\n opened.opener = null;\n } catch {\n // Some environments make `opener` read-only; best-effort only.\n }\n return {\n navigate(url: string) {\n // Restore the no-referrer protection the old \"noreferrer\" feature gave:\n // tag the blank document so the upcoming navigation sends no Referer to\n // the approval page (best-effort; the blank doc is same-origin here).\n try {\n const meta = opened.document.createElement(\"meta\");\n meta.name = \"referrer\";\n meta.content = \"no-referrer\";\n (opened.document.head ?? opened.document.documentElement)?.appendChild(\n meta,\n );\n } catch {\n // Cross-origin/unavailable document: skip, navigation still proceeds.\n }\n opened.location.href = url;\n },\n close() {\n opened.close();\n },\n };\n}\n\n/**\n * Create a connect-flow store.\n *\n * @param transports - Backend transports (`createRequest`, `getStatus`, `readResult`).\n * @param options - Polling/timeout tunables and injectable side effects.\n * @returns A {@link DirectConnectFlow} store.\n */\nexport function createDirectConnectFlow<T = unknown>(\n transports: DirectConnectTransports<T>,\n options: DirectConnectOptions = {},\n): DirectConnectFlow<T> {\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n // `openApprovalWindow` and `browserPlatformPolicy` are resolved lazily at\n // start() (see below) so options swapped in after construction are still\n // honoured — matching the latest-callback pattern the React hook uses for its\n // transports.\n const setTimeoutFn =\n options.setTimeoutFn ??\n ((cb: () => void, ms: number) => globalThis.setTimeout(cb, ms));\n const clearTimeoutFn =\n options.clearTimeoutFn ??\n ((handle: unknown) => {\n globalThis.clearTimeout(handle as never);\n });\n const now = options.now ?? (() => Date.now());\n let state: DirectConnectState<T> = { type: \"idle\" };\n const listeners = new Set<() => void>();\n let pollHandle: unknown = null;\n let running = false;\n // Retained only after status proved this request had a read-ready grant.\n // A retry rechecks that status before trusting the prior consent.\n let approvedRequest: AccessRequest | null = null;\n // Monotonic id for the current start() invocation. reset() (and an\n // immediately following start()) bumps it, so a previous run whose async\n // createRequest is still in flight can detect it has been superseded and\n // avoid touching shared state / the newer run's tab.\n let activeRunId = 0;\n // Holds the tab we opened only while it is still blank (un-navigated). Once\n // navigated to the approval URL we drop the reference so reset/cleanup never\n // closes the live approval tab the user is interacting with.\n let openedWindow: ConnectWindow | null = null;\n\n function emit(): void {\n for (const listener of listeners) listener();\n }\n\n function setState(next: DirectConnectState<T>): void {\n state = next;\n emit();\n }\n\n function clearPoll(): void {\n if (pollHandle !== null) {\n clearTimeoutFn(pollHandle);\n pollHandle = null;\n }\n }\n\n /** Close the opened tab if it is still blank (never navigated). */\n function closeUnnavigatedWindow(): void {\n if (openedWindow) {\n openedWindow.close();\n openedWindow = null;\n }\n }\n\n function isRunningPhase(): boolean {\n return (\n state.type === \"creating\" ||\n state.type === \"awaiting_approval\" ||\n state.type === \"ready_to_open\" ||\n state.type === \"reading\"\n );\n }\n\n async function readAndFinish(request: AccessRequest): Promise<void> {\n setState({ type: \"reading\", request });\n try {\n const result = await transports.readResult(request.requestId);\n if (!running) return;\n setState({ type: \"done\", result });\n } catch (err) {\n if (!running) return;\n setState({ type: \"error\", error: toError(err) });\n } finally {\n running = false;\n }\n }\n\n function scheduleNextPoll(request: AccessRequest, deadline: number): void {\n pollHandle = setTimeoutFn(() => {\n void poll(request, deadline);\n }, pollIntervalMs);\n }\n\n function requestDeadline(request: AccessRequest): number {\n if (request.expiresAt !== undefined) {\n const expiresAt = Date.parse(request.expiresAt);\n if (Number.isFinite(expiresAt)) return expiresAt;\n }\n return now() + timeoutMs;\n }\n\n /**\n * Enter the polling loop from the given initial state (either\n * `awaiting_approval` for desktop/light or `ready_to_open` for mobile-deep).\n * Errors out immediately if the request has already expired.\n */\n function startPolling(\n request: AccessRequest,\n initialState: DirectConnectState<T>,\n ): void {\n setState(initialState);\n const deadline = requestDeadline(request);\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Access request expired\"),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n async function poll(request: AccessRequest, deadline: number): Promise<void> {\n if (!running) return;\n if (now() >= deadline) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(\"Timed out waiting for approval\"),\n });\n return;\n }\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (!running) return;\n running = false;\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (!running) return;\n\n // A pending deep-mobile status may rotate the continuation ticket. Adopt a\n // fresh, still-valid URL so the rendered \"Open Vana\" link always points at a\n // live ticket; ignore it on the desktop/light path.\n if (status.status === \"pending\" && state.type === \"ready_to_open\") {\n const refreshed = normalizeMobileContinuationUrl(\n status.mobileContinuationUrl,\n );\n if (refreshed && refreshed !== state.mobileContinuationUrl) {\n request = { ...request, mobileContinuationUrl: refreshed };\n setState({\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl: refreshed,\n });\n }\n }\n\n if (isReadReadyStatus(status.status)) {\n clearPoll();\n approvedRequest = request;\n await readAndFinish(request);\n return;\n }\n if (\n status.status === \"completed\" ||\n status.status === \"denied\" ||\n status.status === \"expired\"\n ) {\n running = false;\n setState({\n type: \"error\",\n error: new Error(`Access request ${status.status}`),\n });\n return;\n }\n scheduleNextPoll(request, deadline);\n }\n\n const flow: DirectConnectFlow<T> = {\n getState() {\n return state;\n },\n\n subscribe(listener: () => void) {\n listeners.add(listener);\n return () => listeners.delete(listener);\n },\n\n async start(): Promise<void> {\n if (running || isRunningPhase()) return;\n running = true;\n // start() deliberately keeps its established first-run semantics: every\n // explicit start creates a request. Only retryRead() may reuse consent.\n approvedRequest = null;\n const runId = ++activeRunId;\n // Read the platform policy at start time, like openApprovalWindow below,\n // so a policy swapped in after construction (a React rerender forwards\n // options through a ref) still decides this run's destination.\n const browserPlatform = (\n options.browserPlatformPolicy ?? defaultBrowserPlatformPolicy()\n ).current();\n\n // Desktop preserves the pre-mobile synchronous popup contract: open a\n // blank tab while the click's transient activation is live, then navigate\n // it once createRequest returns the approval URL (BUI-622). Mobile never\n // creates that transient tab; deep requests expose one explicit HTTPS\n // link, while light requests retain the manual approvalUrl fallback.\n // Read the opener option at start time so a swapped-in custom opener is\n // still honored for desktop flows.\n const approvalWindow =\n browserPlatform === \"desktop\"\n ? (options.openApprovalWindow ?? defaultOpenApprovalWindow)()\n : null;\n openedWindow = approvalWindow;\n\n setState({ type: \"creating\" });\n\n let request: AccessRequest;\n try {\n request = await transports.createRequest();\n } catch (err) {\n // If we were superseded (reset, possibly + a newer start()) while this\n // request was in flight, only clean up our own tab — never the shared\n // state or the newer run's window.\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n running = false;\n closeUnnavigatedWindow();\n setState({ type: \"error\", error: toError(err) });\n return;\n }\n if (runId !== activeRunId) {\n approvalWindow?.close();\n return;\n }\n // Re-validate the continuation URL at the SDK boundary (defense in depth\n // for custom transports that bypass the default client).\n request = {\n ...request,\n mobileContinuationUrl: normalizeMobileContinuationUrl(\n request.mobileContinuationUrl,\n ),\n };\n\n // The SDK owns only the small mobile-versus-desktop destination choice.\n // A deep Direct request on mobile carries a validated continuation URL;\n // desktop keeps its popup contract, while mobile light exposes the HTTPS\n // approval URL as the existing manual fallback without opening a tab.\n const mobileContinuationUrl =\n browserPlatform === \"mobile\"\n ? request.mobileContinuationUrl\n : undefined;\n\n if (mobileContinuationUrl) {\n // Do not auto-launch: DCR creation is async, so the original Connect\n // gesture can no longer be trusted to retain iOS user activation. Let\n // the UI render an explicit primary \"Open Vana\" link; polling continues\n // in this tab.\n startPolling(request, {\n type: \"ready_to_open\",\n request,\n mobileContinuationUrl,\n });\n return;\n }\n\n // Desktop/light: navigate the synchronously-opened tab to the HTTPS\n // approval URL. `approvalWindow === null` means the popup was blocked;\n // surface it so the UI renders request.approvalUrl as a visible manual\n // \"Open approval\" link instead of hanging. We poll either way, so a manual\n // open still resolves the flow, and the timeout still bounds the wait.\n if (approvalWindow) {\n approvalWindow.navigate(request.approvalUrl);\n // Hand the tab off to the user; we no longer own/close it.\n openedWindow = null;\n }\n startPolling(request, {\n type: \"awaiting_approval\",\n request,\n popupBlocked: approvalWindow === null,\n });\n },\n\n async retryRead(): Promise<DirectConnectRetryOutcome> {\n if (running || isRunningPhase()) {\n throw new Error(\n \"Cannot retry a read while the connect flow is running\",\n );\n }\n\n const request = approvedRequest;\n const parsedExpiry = request?.expiresAt\n ? Date.parse(request.expiresAt)\n : Number.NaN;\n const requestExpired =\n Number.isFinite(parsedExpiry) && now() >= parsedExpiry;\n\n if (request && !requestExpired) {\n running = true;\n const runId = ++activeRunId;\n let status: AccessRequestStatus;\n try {\n status = await transports.getStatus(request.requestId);\n } catch (err) {\n if (runId !== activeRunId) {\n throw new Error(\"Read retry was superseded\");\n }\n running = false;\n const error = toError(err);\n setState({ type: \"error\", error });\n throw error;\n }\n if (runId !== activeRunId) {\n throw new Error(\"Read retry was superseded\");\n }\n if (isReadReadyStatus(status.status)) {\n await readAndFinish(request);\n return \"retried_existing_grant\";\n }\n running = false;\n }\n\n approvedRequest = null;\n await flow.start();\n return \"fresh_approval_required\";\n },\n\n reset(): void {\n running = false;\n approvedRequest = null;\n // Invalidate any in-flight start() so a late createRequest can't clobber\n // a subsequent run.\n activeRunId++;\n clearPoll();\n closeUnnavigatedWindow();\n setState({ type: \"idle\" });\n },\n };\n\n return flow;\n}\n"],"mappings":"AAqBA,SAAS,sCAAsC;AA0I/C,MAAM,2BAA2B;AACjC,MAAM,qBAAqB;AAE3B,SAAS,QAAQ,OAAuB;AACtC,SAAO,iBAAiB,QAAQ,QAAQ,IAAI,MAAM,OAAO,KAAK,CAAC;AACjE;AAEA,SAAS,kBAAkB,QAA2C;AACpE,SAAO,WAAW,cAAc,WAAW;AAC7C;AAEA,MAAM,oBACJ;AAEF,SAAS,+BAA4D;AACnE,SAAO;AAAA,IACL,UAAU;AACR,UAAI,OAAO,cAAc,YAAa,QAAO;AAC7C,YAAM,qBACJ,UAAU,aAAa,cAAc,UAAU,iBAAiB;AAClE,aAAO,kBAAkB,KAAK,UAAU,SAAS,KAAK,qBAClD,WACA;AAAA,IACN;AAAA,EACF;AACF;AAOA,SAAS,4BAAkD;AACzD,MAAI,OAAO,WAAW,eAAe,CAAC,OAAO,KAAM,QAAO;AAI1D,QAAM,SAAS,OAAO,KAAK,IAAI,QAAQ;AACvC,MAAI,CAAC,OAAQ,QAAO;AAGpB,MAAI;AACF,WAAO,SAAS;AAAA,EAClB,QAAQ;AAAA,EAER;AACA,SAAO;AAAA,IACL,SAAS,KAAa;AAIpB,UAAI;AACF,cAAM,OAAO,OAAO,SAAS,cAAc,MAAM;AACjD,aAAK,OAAO;AACZ,aAAK,UAAU;AACf,SAAC,OAAO,SAAS,QAAQ,OAAO,SAAS,kBAAkB;AAAA,UACzD;AAAA,QACF;AAAA,MACF,QAAQ;AAAA,MAER;AACA,aAAO,SAAS,OAAO;AAAA,IACzB;AAAA,IACA,QAAQ;AACN,aAAO,MAAM;AAAA,IACf;AAAA,EACF;AACF;AASO,SAAS,wBACd,YACA,UAAgC,CAAC,GACX;AACtB,QAAM,iBAAiB,QAAQ,kBAAkB;AACjD,QAAM,YAAY,QAAQ,aAAa;AAKvC,QAAM,eACJ,QAAQ,iBACP,CAAC,IAAgB,OAAe,WAAW,WAAW,IAAI,EAAE;AAC/D,QAAM,iBACJ,QAAQ,mBACP,CAAC,WAAoB;AACpB,eAAW,aAAa,MAAe;AAAA,EACzC;AACF,QAAM,MAAM,QAAQ,QAAQ,MAAM,KAAK,IAAI;AAC3C,MAAI,QAA+B,EAAE,MAAM,OAAO;AAClD,QAAM,YAAY,oBAAI,IAAgB;AACtC,MAAI,aAAsB;AAC1B,MAAI,UAAU;AAGd,MAAI,kBAAwC;AAK5C,MAAI,cAAc;AAIlB,MAAI,eAAqC;AAEzC,WAAS,OAAa;AACpB,eAAW,YAAY,UAAW,UAAS;AAAA,EAC7C;AAEA,WAAS,SAAS,MAAmC;AACnD,YAAQ;AACR,SAAK;AAAA,EACP;AAEA,WAAS,YAAkB;AACzB,QAAI,eAAe,MAAM;AACvB,qBAAe,UAAU;AACzB,mBAAa;AAAA,IACf;AAAA,EACF;AAGA,WAAS,yBAA+B;AACtC,QAAI,cAAc;AAChB,mBAAa,MAAM;AACnB,qBAAe;AAAA,IACjB;AAAA,EACF;AAEA,WAAS,iBAA0B;AACjC,WACE,MAAM,SAAS,cACf,MAAM,SAAS,uBACf,MAAM,SAAS,mBACf,MAAM,SAAS;AAAA,EAEnB;AAEA,iBAAe,cAAc,SAAuC;AAClE,aAAS,EAAE,MAAM,WAAW,QAAQ,CAAC;AACrC,QAAI;AACF,YAAM,SAAS,MAAM,WAAW,WAAW,QAAQ,SAAS;AAC5D,UAAI,CAAC,QAAS;AACd,eAAS,EAAE,MAAM,QAAQ,OAAO,CAAC;AAAA,IACnC,SAAS,KAAK;AACZ,UAAI,CAAC,QAAS;AACd,eAAS,EAAE,MAAM,SAAS,OAAO,QAAQ,GAAG,EAAE,CAAC;AAAA,IACjD,UAAE;AACA,gBAAU;AAAA,IACZ;AAAA,EACF;AAEA,WAAS,iBAAiB,SAAwB,UAAwB;AACxE,iBAAa,aAAa,MAAM;AAC9B,WAAK,KAAK,SAAS,QAAQ;AAAA,IAC7B,GAAG,cAAc;AAAA,EACnB;AAEA,WAAS,gBAAgB,SAAgC;AACvD,QAAI,QAAQ,cAAc,QAAW;AACnC,YAAM,YAAY,KAAK,MAAM,QAAQ,SAAS;AAC9C,UAAI,OAAO,SAAS,SAAS,EAAG,QAAO;AAAA,IACzC;AACA,WAAO,IAAI,IAAI;AAAA,EACjB;AAOA,WAAS,aACP,SACA,cACM;AACN,aAAS,YAAY;AACrB,UAAM,WAAW,gBAAgB,OAAO;AACxC,QAAI,IAAI,KAAK,UAAU;AACrB,gBAAU;AACV,eAAS;AAAA,QACP,MAAM;AAAA,QACN,OAAO,IAAI,MAAM,wBAAwB;AAAA,MAC3C,CAAC;AACD;AAAA,IACF;AACA,qBAAiB,SAAS,QAAQ;AAAA,EACpC;AAEA,iBAAe,KAAK,SAAwB,UAAiC;AAC3E,QAAI,CAAC,QAAS;AACd,QAAI,IAAI,KAAK,UAAU;AACrB,gBAAU;AACV,eAAS;AAAA,QACP,MAAM;AAAA,QACN,OAAO,IAAI,MAAM,gCAAgC;AAAA,MACnD,CAAC;AACD;AAAA,IACF;AACA,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,WAAW,UAAU,QAAQ,SAAS;AAAA,IACvD,SAAS,KAAK;AACZ,UAAI,CAAC,QAAS;AACd,gBAAU;AACV,eAAS,EAAE,MAAM,SAAS,OAAO,QAAQ,GAAG,EAAE,CAAC;AAC/C;AAAA,IACF;AACA,QAAI,CAAC,QAAS;AAKd,QAAI,OAAO,WAAW,aAAa,MAAM,SAAS,iBAAiB;AACjE,YAAM,YAAY;AAAA,QAChB,OAAO;AAAA,MACT;AACA,UAAI,aAAa,cAAc,MAAM,uBAAuB;AAC1D,kBAAU,EAAE,GAAG,SAAS,uBAAuB,UAAU;AACzD,iBAAS;AAAA,UACP,MAAM;AAAA,UACN;AAAA,UACA,uBAAuB;AAAA,QACzB,CAAC;AAAA,MACH;AAAA,IACF;AAEA,QAAI,kBAAkB,OAAO,MAAM,GAAG;AACpC,gBAAU;AACV,wBAAkB;AAClB,YAAM,cAAc,OAAO;AAC3B;AAAA,IACF;AACA,QACE,OAAO,WAAW,eAClB,OAAO,WAAW,YAClB,OAAO,WAAW,WAClB;AACA,gBAAU;AACV,eAAS;AAAA,QACP,MAAM;AAAA,QACN,OAAO,IAAI,MAAM,kBAAkB,OAAO,MAAM,EAAE;AAAA,MACpD,CAAC;AACD;AAAA,IACF;AACA,qBAAiB,SAAS,QAAQ;AAAA,EACpC;AAEA,QAAM,OAA6B;AAAA,IACjC,WAAW;AACT,aAAO;AAAA,IACT;AAAA,IAEA,UAAU,UAAsB;AAC9B,gBAAU,IAAI,QAAQ;AACtB,aAAO,MAAM,UAAU,OAAO,QAAQ;AAAA,IACxC;AAAA,IAEA,MAAM,QAAuB;AAC3B,UAAI,WAAW,eAAe,EAAG;AACjC,gBAAU;AAGV,wBAAkB;AAClB,YAAM,QAAQ,EAAE;AAIhB,YAAM,mBACJ,QAAQ,yBAAyB,6BAA6B,GAC9D,QAAQ;AASV,YAAM,iBACJ,oBAAoB,aACf,QAAQ,sBAAsB,2BAA2B,IAC1D;AACN,qBAAe;AAEf,eAAS,EAAE,MAAM,WAAW,CAAC;AAE7B,UAAI;AACJ,UAAI;AACF,kBAAU,MAAM,WAAW,cAAc;AAAA,MAC3C,SAAS,KAAK;AAIZ,YAAI,UAAU,aAAa;AACzB,0BAAgB,MAAM;AACtB;AAAA,QACF;AACA,kBAAU;AACV,+BAAuB;AACvB,iBAAS,EAAE,MAAM,SAAS,OAAO,QAAQ,GAAG,EAAE,CAAC;AAC/C;AAAA,MACF;AACA,UAAI,UAAU,aAAa;AACzB,wBAAgB,MAAM;AACtB;AAAA,MACF;AAGA,gBAAU;AAAA,QACR,GAAG;AAAA,QACH,uBAAuB;AAAA,UACrB,QAAQ;AAAA,QACV;AAAA,MACF;AAMA,YAAM,wBACJ,oBAAoB,WAChB,QAAQ,wBACR;AAEN,UAAI,uBAAuB;AAKzB,qBAAa,SAAS;AAAA,UACpB,MAAM;AAAA,UACN;AAAA,UACA;AAAA,QACF,CAAC;AACD;AAAA,MACF;AAOA,UAAI,gBAAgB;AAClB,uBAAe,SAAS,QAAQ,WAAW;AAE3C,uBAAe;AAAA,MACjB;AACA,mBAAa,SAAS;AAAA,QACpB,MAAM;AAAA,QACN;AAAA,QACA,cAAc,mBAAmB;AAAA,MACnC,CAAC;AAAA,IACH;AAAA,IAEA,MAAM,YAAgD;AACpD,UAAI,WAAW,eAAe,GAAG;AAC/B,cAAM,IAAI;AAAA,UACR;AAAA,QACF;AAAA,MACF;AAEA,YAAM,UAAU;AAChB,YAAM,eAAe,SAAS,YAC1B,KAAK,MAAM,QAAQ,SAAS,IAC5B,OAAO;AACX,YAAM,iBACJ,OAAO,SAAS,YAAY,KAAK,IAAI,KAAK;AAE5C,UAAI,WAAW,CAAC,gBAAgB;AAC9B,kBAAU;AACV,cAAM,QAAQ,EAAE;AAChB,YAAI;AACJ,YAAI;AACF,mBAAS,MAAM,WAAW,UAAU,QAAQ,SAAS;AAAA,QACvD,SAAS,KAAK;AACZ,cAAI,UAAU,aAAa;AACzB,kBAAM,IAAI,MAAM,2BAA2B;AAAA,UAC7C;AACA,oBAAU;AACV,gBAAM,QAAQ,QAAQ,GAAG;AACzB,mBAAS,EAAE,MAAM,SAAS,MAAM,CAAC;AACjC,gBAAM;AAAA,QACR;AACA,YAAI,UAAU,aAAa;AACzB,gBAAM,IAAI,MAAM,2BAA2B;AAAA,QAC7C;AACA,YAAI,kBAAkB,OAAO,MAAM,GAAG;AACpC,gBAAM,cAAc,OAAO;AAC3B,iBAAO;AAAA,QACT;AACA,kBAAU;AAAA,MACZ;AAEA,wBAAkB;AAClB,YAAM,KAAK,MAAM;AACjB,aAAO;AAAA,IACT;AAAA,IAEA,QAAc;AACZ,gBAAU;AACV,wBAAkB;AAGlB;AACA,gBAAU;AACV,6BAAuB;AACvB,eAAS,EAAE,MAAM,OAAO,CAAC;AAAA,IAC3B;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}