@opendatalabs/vana-sdk 4.1.0 → 4.3.0

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 (46) hide show
  1. package/README.md +47 -0
  2. package/dist/direct/access-request-client.cjs +3 -0
  3. package/dist/direct/access-request-client.cjs.map +1 -1
  4. package/dist/direct/access-request-client.js +3 -0
  5. package/dist/direct/access-request-client.js.map +1 -1
  6. package/dist/direct/controller.cjs +43 -2
  7. package/dist/direct/controller.cjs.map +1 -1
  8. package/dist/direct/controller.d.ts +33 -0
  9. package/dist/direct/controller.js +46 -2
  10. package/dist/direct/controller.js.map +1 -1
  11. package/dist/direct/grant-union.cjs +129 -0
  12. package/dist/direct/grant-union.cjs.map +1 -0
  13. package/dist/direct/grant-union.d.ts +138 -0
  14. package/dist/direct/grant-union.js +102 -0
  15. package/dist/direct/grant-union.js.map +1 -0
  16. package/dist/direct/grant-union.test.d.ts +1 -0
  17. package/dist/direct/types.cjs.map +1 -1
  18. package/dist/direct/types.d.ts +43 -0
  19. package/dist/direct/types.js.map +1 -1
  20. package/dist/errors.cjs +2 -2
  21. package/dist/errors.cjs.map +1 -1
  22. package/dist/errors.d.ts +7 -1
  23. package/dist/errors.js +2 -2
  24. package/dist/errors.js.map +1 -1
  25. package/dist/index.browser.d.ts +2 -1
  26. package/dist/index.browser.js +99 -0
  27. package/dist/index.browser.js.map +3 -3
  28. package/dist/index.node.cjs +945 -418
  29. package/dist/index.node.cjs.map +4 -4
  30. package/dist/index.node.d.ts +2 -1
  31. package/dist/index.node.js +801 -276
  32. package/dist/index.node.js.map +4 -4
  33. package/dist/protocol/jobs-client.cjs +196 -25
  34. package/dist/protocol/jobs-client.cjs.map +1 -1
  35. package/dist/protocol/jobs-client.d.ts +39 -1
  36. package/dist/protocol/jobs-client.js +199 -26
  37. package/dist/protocol/jobs-client.js.map +1 -1
  38. package/dist/protocol/jobs.cjs.map +1 -1
  39. package/dist/protocol/jobs.d.ts +71 -1
  40. package/dist/protocol/jobs.js.map +1 -1
  41. package/dist/server.cjs +9 -0
  42. package/dist/server.cjs.map +1 -1
  43. package/dist/server.d.ts +2 -1
  44. package/dist/server.js +10 -0
  45. package/dist/server.js.map +1 -1
  46. package/package.json +1 -1
@@ -2,6 +2,11 @@ import { privateKeyToAccount } from "viem/accounts";
2
2
  import { parseScope } from "../protocol/scopes.js";
3
3
  import { parseScopeEntry } from "../protocol/scope-actions.js";
4
4
  import { createEscrowGatewayClient } from "../protocol/escrow.js";
5
+ import { createGatewayClient } from "../protocol/gateway.js";
6
+ import {
7
+ mergeWithLiveGrant,
8
+ unionGrantScopes
9
+ } from "./grant-union.js";
5
10
  import { CONTRACTS } from "../generated/addresses.js";
6
11
  import {
7
12
  createDefaultAccessRequestClient,
@@ -73,6 +78,35 @@ function createDirectDataController(config) {
73
78
  nonceSource: config.escrow?.nonceSource,
74
79
  signTypedData
75
80
  };
81
+ let gateway = config.gateway;
82
+ async function resolveGrantUnion(owner, options) {
83
+ const requestedOnly = {
84
+ ...unionGrantScopes([], config.scopes, options.removeScopes),
85
+ scopes: [...config.scopes]
86
+ };
87
+ if (!options.merge) {
88
+ return { status: "disabled", ...requestedOnly };
89
+ }
90
+ if (!owner) {
91
+ return { status: "owner_unknown", ...requestedOnly };
92
+ }
93
+ gateway ??= createGatewayClient(endpoints.escrowGatewayUrl);
94
+ try {
95
+ return await mergeWithLiveGrant({
96
+ gateway,
97
+ owner,
98
+ appAddress: account.address,
99
+ scopes: config.scopes,
100
+ removeScopes: options.removeScopes
101
+ });
102
+ } catch (error) {
103
+ return {
104
+ status: "unavailable",
105
+ reason: error instanceof Error ? error.message : String(error),
106
+ ...requestedOnly
107
+ };
108
+ }
109
+ }
76
110
  return {
77
111
  appAddress: account.address,
78
112
  getAppAddress() {
@@ -90,17 +124,27 @@ function createDirectDataController(config) {
90
124
  if (input.questions !== void 0) {
91
125
  validateAccessRequestQuestions(input.questions, config.scopes);
92
126
  }
93
- return accessRequestClient.createAccessRequest({
127
+ const removeScopes = input.removeScopes ?? [];
128
+ for (const entry of removeScopes) {
129
+ parseScopeEntry(entry);
130
+ }
131
+ const grantUnion = await resolveGrantUnion(input.owner, {
132
+ merge: input.mergeLiveGrant !== false,
133
+ removeScopes
134
+ });
135
+ const created = await accessRequestClient.createAccessRequest({
94
136
  appAddress: account.address,
95
137
  app: config.app,
96
138
  source: config.source,
97
- scopes: config.scopes,
139
+ scopes: grantUnion.scopes,
140
+ ...removeScopes.length > 0 ? { removeScopes } : {},
98
141
  returnUrl: input.returnUrl,
99
142
  network,
100
143
  ...input.foregroundDelivery !== void 0 ? { foregroundDelivery: input.foregroundDelivery } : {},
101
144
  ...input.questions !== void 0 ? { questions: input.questions } : {},
102
145
  ...input.idempotencyKey !== void 0 ? { idempotencyKey: input.idempotencyKey } : {}
103
146
  });
147
+ return { ...created, scopes: grantUnion.scopes, grantUnion };
104
148
  },
105
149
  async getAccessRequestStatus(requestId) {
106
150
  return accessRequestClient.getAccessRequestStatus(requestId);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/direct/controller.ts"],"sourcesContent":["/**\n * Direct Data Controller — the server-side facade for the two-tab Data\n * Portability flow.\n *\n * @remarks\n * One controller owns an app's private key, source, scopes, app identity, and\n * payment flow. It exposes the three methods the builder guide documents:\n *\n * - {@link DirectDataController.createAccessRequest} — start an approval request.\n * - {@link DirectDataController.getAccessRequestStatus} — poll while the Vana tab is open.\n * - {@link DirectDataController.readApprovedData} — read from the Personal Server,\n * handling 402 Payment Required.\n *\n * Access requests are created through the Vana Account access-request API; the\n * Personal Server read uses Web3Signed auth; and payment uses the DPv2 escrow\n * surface (`protocol/escrow`) — when a read returns `402`, the controller signs\n * a `GenericPayment` with the app key, settles it through the escrow gateway,\n * and retries.\n *\n * @category Direct\n * @module direct/controller\n */\n\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport type { Hex } from \"viem\";\nimport type { Web3SignedSignFn } from \"../auth/web3-signed-builder\";\nimport { parseScope } from \"../protocol/scopes\";\nimport { parseScopeEntry } from \"../protocol/scope-actions\";\nimport { createEscrowGatewayClient } from \"../protocol/escrow\";\nimport { CONTRACTS } from \"../generated/addresses\";\nimport {\n createDefaultAccessRequestClient,\n validateAccessRequestQuestions,\n type FetchLike,\n} from \"./access-request-client\";\nimport {\n getDirectDefaultNetwork,\n getDirectEndpoints,\n getDirectNetworkChainId,\n} from \"./endpoints\";\nimport {\n AccessNotApprovedError,\n DirectConfigError,\n ScopeNotApprovedError,\n} from \"./errors\";\nimport {\n type EscrowPaymentConfig,\n type SignTypedDataFn,\n} from \"./escrow-payment\";\nimport {\n readPersonalServerData,\n type PersonalServerFetch,\n type PersonalServerTransportRetryOptions,\n} from \"./personal-server-read\";\nimport type {\n AccessRequest,\n AccessRequestClient,\n AccessRequestQuestion,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n AppIdentity,\n DirectAppConfig,\n DirectEnv,\n DirectNetwork,\n DirectPaymentResponseMetadata,\n DirectServiceEndpoints,\n ForegroundDelivery,\n MultiScopeDataResult,\n} from \"./types\";\n\n/** Configuration for {@link createDirectDataController}. */\nexport interface DirectDataControllerConfig {\n /** Target environment. Defaults to `\"production\"`. */\n env?: DirectEnv;\n /**\n * Target Vana network for chain-aware defaults. Defaults to the selected\n * environment's historical network (`mainnet` for production, `moksha` for\n * dev). Use `network: \"moksha\"` with the default production env for\n * production app/API URLs on testnet.\n */\n network?: DirectNetwork;\n /**\n * The app private key (`0x`-prefixed, 32 bytes). Server-side only — this key\n * is the app's on-chain identity and is never exposed to the browser.\n */\n appPrivateKey?: string;\n /**\n * @deprecated Use {@link DirectDataControllerConfig.appPrivateKey}. Accepted as\n * a backwards-compatible alias; if both are set, `appPrivateKey` wins.\n */\n builderPrivateKey?: string;\n /** App identity advertised during approval. */\n app: DirectAppConfig;\n /** Data source key (e.g. `\"icloud_notes\"`). */\n source: string;\n /**\n * Grant scope entries to request. At least one required.\n *\n * Each entry is `[operation:]scope` (see `parseScopeEntry`): a bare entry\n * such as `\"icloud_notes.notes\"` requests read, and `\"write:coach.weekly\"`\n * requests write. The entries are carried through to the access request\n * verbatim and become the grant's `scopes`, so a request can mix both\n * (`[\"oura.sleep\", \"coach.weekly\", \"write:coach.weekly\"]`).\n *\n * The scope part must be a concrete `{source}.{category}[.{subcategory}]`\n * scope: this flow reads approved scopes back one by one, so wildcard\n * patterns (`chatgpt.*`, `write:chatgpt.*`) are not accepted here for\n * either operation.\n */\n scopes: string[];\n /**\n * Override the resolved service endpoints (partial). Useful for pointing at a\n * non-standard deployment.\n */\n endpoints?: Partial<DirectServiceEndpoints>;\n /**\n * Client for the Vana Account access-request API. Defaults to a client against\n * the resolved Vana Account endpoints; inject your own to point at a custom\n * deployment or to supply a test double.\n */\n accessRequestClient?: AccessRequestClient;\n /**\n * Escrow settlement config used when a Personal Server read returns `402`.\n *\n * @remarks\n * Wires the DPv2 escrow gateway (`protocol/escrow`). The controller supplies\n * the EIP-712 `signTypedData` from the app key automatically.\n *\n * When omitted (or partially omitted), the SDK derives defaults from the\n * per-network endpoints table and the contract registry:\n * - `client` defaults to a gateway client at `endpoints.escrowGatewayUrl`\n * - `escrowContract` defaults to `CONTRACTS.DataPortabilityEscrow.addresses[chainId]`\n * - `chainId` defaults to the controller's resolved chain id\n *\n * Provide this field only to override a specific default.\n */\n escrow?: Partial<DirectEscrowConfig>;\n /** `fetch` used by the default access-request client. Defaults to `globalThis.fetch`. */\n fetchFn?: FetchLike;\n /** `fetch` used for the Personal Server read. Defaults to `globalThis.fetch`. */\n personalServerFetch?: PersonalServerFetch;\n /**\n * Transport-retry knobs for the Personal Server read\n * ({@link PersonalServerTransportRetryOptions}). Defaults to 3 attempts with\n * exponential backoff. Retries fire only when fetch throws (the browser-PS\n * relay reconnect window), never on a received HTTP status, and never\n * re-sign a payment.\n */\n personalServerTransportRetry?: PersonalServerTransportRetryOptions;\n}\n\n/**\n * Controller-level escrow config — the {@link EscrowPaymentConfig} minus the\n * `signTypedData` and `chainId` the controller injects itself.\n */\nexport interface DirectEscrowConfig extends Omit<\n EscrowPaymentConfig,\n \"signTypedData\" | \"chainId\"\n> {\n /**\n * Chain id for the EIP-712 domain. Defaults to the controller's environment\n * (1480 for mainnet, 14800 for moksha).\n */\n chainId?: number;\n}\n\n/**\n * Server-side controller for the direct Data Portability flow.\n *\n * @typeParam T - Shape of the data returned by {@link DirectDataController.readApprovedData}.\n */\nexport interface DirectDataController {\n /** The on-chain address of the app, derived from `appPrivateKey`. */\n readonly appAddress: string;\n\n /**\n * The app's on-chain address — the address to fund and inspect in the Builder\n * activity report. Equivalent to {@link DirectDataController.appAddress}.\n *\n * @returns The app's `0x`-prefixed address.\n */\n getAppAddress(): string;\n\n /**\n * The app's full identity: its configured id/name/homepage plus the derived\n * on-chain address. Useful for telling builders which app address to fund or\n * look up.\n *\n * @returns `{ id, name, homepageUrl, address }`.\n */\n getAppIdentity(): AppIdentity;\n\n /**\n * Create an access request the user can approve.\n *\n * @param input - The post-approval return URL and optional create retry key.\n * @returns The request id, HTTPS approval URL, and — for a pending deep Direct\n * request on mobile — an optional HTTPS `mobileContinuationUrl`.\n */\n createAccessRequest(input: {\n returnUrl: string;\n /** Optional foreground mobile delivery callback. */\n foregroundDelivery?: ForegroundDelivery;\n /**\n * Derivative questions to carry on the request (1 to 4). Each question\n * asks the user's Personal Server to compute its `derivedScope` from\n * `sourceScopes` the app never reads; the derived scope must also appear\n * in the controller's `scopes` as a bare read entry. Validated eagerly\n * against the configured scopes before the request is sent. See\n * {@link AccessRequestQuestion}.\n */\n questions?: AccessRequestQuestion[];\n /**\n * Stable retry key when the caller retries after an uncertain response.\n * Each create without one gets its own generated key.\n */\n idempotencyKey?: string;\n }): Promise<AccessRequest>;\n\n /**\n * Fetch the current status of an access request.\n *\n * @param requestId - The `dcr_*` id from {@link DirectDataController.createAccessRequest}.\n * @returns `{ status, personalServerUrl?, grantId?, scope?, scopes? }`.\n */\n getAccessRequestStatus(requestId: string): Promise<AccessRequestStatus>;\n\n /**\n * Read the approved data from the user's Personal Server.\n *\n * @remarks\n * Resolves the request to its grant + Personal Server and performs a Web3Signed\n * read. Hides the `402 Payment Required` flow by default: if a read needs\n * payment, it signs the Personal Server's payment challenge, retries with\n * `X-PAYMENT`, and attaches shape-validated but unauthenticated\n * {@link DirectPaymentResponseMetadata} under `payment` when the Personal\n * Server returns it. After a successful read, the controller acknowledges\n * the DCR so Vana Web can close/redirect the approval tab.\n *\n * A request can approve several scopes. This reads **one** of them — `scope`\n * when given, otherwise the first approved scope. Use\n * {@link DirectDataController.readAllApprovedData} to read them all.\n *\n * Acknowledging moves the DCR to `completed`, which is terminal and no longer\n * read-ready. To read several scopes with your own loop, pass\n * `acknowledge: false` on every call but the last.\n *\n * @param input - The `dcr_*` request id, the optional `scope` to read, and an\n * optional `acknowledge` flag (default `true`).\n * @returns `{ scope, data, payment? }`.\n * @throws {@link AccessNotApprovedError} if the request is not approved.\n * @throws {@link ScopeNotApprovedError} if `scope` is not an approved scope.\n * @throws {@link PaymentRequiredError} if payment is required but unsettled.\n */\n readApprovedData<T = unknown>(input: {\n requestId: string;\n scope?: string;\n acknowledge?: boolean;\n }): Promise<ApprovedDataResult<T>>;\n\n /**\n * Read every scope the user approved on a request.\n *\n * @remarks\n * Reads the scopes in approval order, then acknowledges the DCR **once**,\n * after the last read — acknowledging earlier would move the request to\n * `completed` and make the remaining scopes unreadable.\n *\n * Each scope is a separate Personal Server read that settles its own\n * `data_access` fee from escrow, so reading N scopes costs N times a\n * single-scope read. The one-off registration fee is charged per grant, not\n * per scope.\n *\n * A scope that fails does not abort the rest: successes land in `results` and\n * failures in `errors`, because the fees for earlier scopes are already spent.\n * If any scope fails the request is left unacknowledged, so the scopes that\n * failed stay retryable — read them with `readApprovedData({ scope })` and\n * acknowledge on the last one.\n *\n * @param input - The `dcr_*` request id to read.\n * @returns `{ results, errors }`, both keyed by scope.\n * @throws {@link AccessNotApprovedError} if the request is not approved.\n */\n readAllApprovedData<T = unknown>(input: {\n requestId: string;\n }): Promise<MultiScopeDataResult<T>>;\n}\n\nfunction isHexPrivateKey(value: string): value is Hex {\n return /^0x[0-9a-fA-F]{64}$/.test(value);\n}\n\n// A DCR is read-ready only while the grant exists and the Personal Server is\n// still serving it: `approved` (durable PS) or `ready_for_read` (browser PS).\n// `completed` is terminal — the app already read and acknowledged, and the\n// browser PS may be gone — so it is deliberately excluded here.\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\n/**\n * Create a {@link DirectDataController}.\n *\n * @param config - Controller configuration (env, key, app identity, source, scopes).\n * @returns A ready-to-use controller.\n * @throws {@link DirectConfigError} when the key is missing or malformed, when\n * `scopes` is empty, or when no escrow contract can be resolved.\n * @throws InvalidScopeEntryError when a `scopes` entry does not fit the\n * `[operation:]scope` grammar (an unknown operation prefix such as `delete:`).\n * @throws ZodError when the scope part of an entry is not a valid scope.\n */\nexport function createDirectDataController(\n config: DirectDataControllerConfig,\n): DirectDataController {\n // `appPrivateKey` is the documented field; `builderPrivateKey` is a\n // deprecated alias kept for backwards compatibility.\n const privateKey = config.appPrivateKey ?? config.builderPrivateKey;\n if (!privateKey || !isHexPrivateKey(privateKey)) {\n throw new DirectConfigError(\n \"appPrivateKey must be a 0x-prefixed 32-byte hex string\",\n );\n }\n if (!config.scopes || config.scopes.length === 0) {\n throw new DirectConfigError(\"At least one scope is required\");\n }\n // Validate scopes eagerly so misconfiguration fails at construction. Each\n // element is a grant scope entry (`[operation:]scope`), so the operation\n // prefix is stripped first and only the scope part is checked against the\n // scope grammar — `write:coach.weekly` is a valid write-grant request, and\n // an unknown operation (`delete:x`) throws rather than being taken as read.\n // The entries themselves are passed through to the access request verbatim,\n // prefix included.\n for (const entry of config.scopes) {\n parseScope(parseScopeEntry(entry).scope);\n }\n\n const env: DirectEnv = config.env ?? \"production\";\n const network: DirectNetwork = config.network ?? getDirectDefaultNetwork(env);\n const defaultEndpoints = getDirectEndpoints(env);\n const chainId = config.endpoints?.chainId ?? getDirectNetworkChainId(network);\n const endpoints: DirectServiceEndpoints = {\n ...defaultEndpoints,\n ...config.endpoints,\n chainId,\n };\n\n const account = privateKeyToAccount(privateKey as Hex);\n const signMessage: Web3SignedSignFn = (message: string) =>\n account.signMessage({ message });\n // viem's account.signTypedData satisfies the structural SignTypedDataFn used\n // by the escrow GenericPayment signer.\n const signTypedData = account.signTypedData as unknown as SignTypedDataFn;\n const accessRequestClient: AccessRequestClient =\n config.accessRequestClient ??\n createDefaultAccessRequestClient({\n baseUrl: endpoints.accessRequestBaseUrl,\n approvalBaseUrl: endpoints.approvalAppBaseUrl,\n env,\n fetchFn: config.fetchFn,\n appAddress: account.address,\n signMessage,\n });\n\n // Build the escrow payment config, defaulting from the per-network endpoints\n // table and the contract registry when `config.escrow` is omitted or partial.\n const escrowChainId = config.escrow?.chainId ?? chainId;\n const defaultEscrowContract =\n CONTRACTS.DataPortabilityEscrow.addresses[\n escrowChainId as keyof typeof CONTRACTS.DataPortabilityEscrow.addresses\n ] ?? undefined;\n if (!config.escrow?.escrowContract && !defaultEscrowContract) {\n throw new DirectConfigError(\n `No DataPortabilityEscrow address found in the registry for chainId ${escrowChainId}. ` +\n `Provide an explicit escrow.escrowContract in the controller config.`,\n );\n }\n const escrow: EscrowPaymentConfig = {\n client:\n config.escrow?.client ??\n createEscrowGatewayClient(endpoints.escrowGatewayUrl),\n escrowContract:\n config.escrow?.escrowContract ?? (defaultEscrowContract as `0x${string}`),\n chainId: escrowChainId,\n nonceSource: config.escrow?.nonceSource,\n signTypedData,\n };\n\n return {\n appAddress: account.address,\n\n getAppAddress(): string {\n return account.address;\n },\n\n getAppIdentity(): AppIdentity {\n return {\n id: config.app.id,\n name: config.app.name,\n homepageUrl: config.app.homepageUrl,\n address: account.address,\n };\n },\n\n async createAccessRequest(input): Promise<AccessRequest> {\n // Fail fast even with an injected client: a malformed question would be\n // rejected by the access-request service anyway, after a signed round\n // trip.\n if (input.questions !== undefined) {\n validateAccessRequestQuestions(input.questions, config.scopes);\n }\n return accessRequestClient.createAccessRequest({\n appAddress: account.address,\n app: config.app,\n source: config.source,\n scopes: config.scopes,\n returnUrl: input.returnUrl,\n network,\n ...(input.foregroundDelivery !== undefined\n ? { foregroundDelivery: input.foregroundDelivery }\n : {}),\n ...(input.questions !== undefined\n ? { questions: input.questions }\n : {}),\n ...(input.idempotencyKey !== undefined\n ? { idempotencyKey: input.idempotencyKey }\n : {}),\n });\n },\n\n async getAccessRequestStatus(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n return accessRequestClient.getAccessRequestStatus(requestId);\n },\n\n async readApprovedData<T = unknown>(input: {\n requestId: string;\n scope?: string;\n acknowledge?: boolean;\n }): Promise<ApprovedDataResult<T>> {\n const status = await requireReadReady(input.requestId);\n const scope = resolveRequestedScope(status, input.scope);\n\n const result = await readScope<T>(status, scope);\n if (input.acknowledge !== false) {\n await acknowledgeQuietly(input.requestId);\n }\n return result;\n },\n\n async readAllApprovedData<T = unknown>(input: {\n requestId: string;\n }): Promise<MultiScopeDataResult<T>> {\n const status = await requireReadReady(input.requestId);\n const scopes = approvedScopes(status);\n\n const results: Record<string, ApprovedDataResult<T>> = {};\n const errors: Record<string, Error> = {};\n // Sequential, not parallel: each read settles its own escrow payment and\n // the default nonce source is process-local, so concurrent reads would\n // race on the payment nonce.\n for (const scope of scopes) {\n try {\n results[scope] = await readScope<T>(status, scope);\n } catch (error) {\n errors[scope] =\n error instanceof Error ? error : new Error(String(error));\n }\n }\n\n // Acknowledge only after the last read, and only if every scope read —\n // acking moves the DCR to `completed`, which is terminal and no longer\n // read-ready, so acking on a partial failure would make the scope that\n // failed impossible to retry.\n if (Object.keys(errors).length === 0) {\n await acknowledgeQuietly(input.requestId);\n }\n\n return { results, errors };\n },\n };\n\n async function requireReadReady(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n const status = await accessRequestClient.getAccessRequestStatus(requestId);\n // `scope` and `scopes` are both optional on the public status type, and a\n // client may return either one — require at least one approved scope rather\n // than the singular field specifically.\n if (\n !isReadReadyStatus(status.status) ||\n !status.personalServerUrl ||\n !status.grantId ||\n approvedScopes(status).length === 0\n ) {\n throw new AccessNotApprovedError(\n \"Request is not approved or is missing grantId/scope/personalServerUrl\",\n {\n requestId,\n status: status.status,\n hasPersonalServerUrl: Boolean(status.personalServerUrl),\n hasGrantId: Boolean(status.grantId),\n hasScope: approvedScopes(status).length > 0,\n },\n );\n }\n return status;\n }\n\n /** Approved scopes in approval order, falling back to the single `scope`. */\n function approvedScopes(status: AccessRequestStatus): string[] {\n if (status.scopes && status.scopes.length > 0) return status.scopes;\n return status.scope ? [status.scope] : [];\n }\n\n /**\n * Resolve which scope to read. Rejects an unapproved scope up front so it\n * never reaches the Personal Server and never settles a fee.\n */\n function resolveRequestedScope(\n status: AccessRequestStatus,\n requested?: string,\n ): string {\n const scopes = approvedScopes(status);\n if (requested === undefined) return scopes[0];\n if (!scopes.includes(requested)) {\n throw new ScopeNotApprovedError(\n `Scope \"${requested}\" is not approved on this request`,\n { requestedScope: requested, approvedScopes: scopes },\n );\n }\n return requested;\n }\n\n async function readScope<T>(\n status: AccessRequestStatus,\n scope: string,\n ): Promise<ApprovedDataResult<T>> {\n const result = await readPersonalServerData({\n personalServerUrl: status.personalServerUrl as string,\n scope,\n grantId: status.grantId as string,\n payerAddress: account.address,\n signMessage,\n escrow,\n fetchFn: config.personalServerFetch,\n transportRetry: config.personalServerTransportRetry,\n });\n return { scope, data: result.data as T, payment: result.payment };\n }\n\n async function acknowledgeQuietly(requestId: string): Promise<void> {\n try {\n await accessRequestClient.acknowledgeRead?.(requestId);\n } catch {\n // The read already succeeded; ack only drives Vana Web completion UX.\n }\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/direct/controller.ts"],"sourcesContent":["/**\n * Direct Data Controller — the server-side facade for the two-tab Data\n * Portability flow.\n *\n * @remarks\n * One controller owns an app's private key, source, scopes, app identity, and\n * payment flow. It exposes the three methods the builder guide documents:\n *\n * - {@link DirectDataController.createAccessRequest} — start an approval request.\n * - {@link DirectDataController.getAccessRequestStatus} — poll while the Vana tab is open.\n * - {@link DirectDataController.readApprovedData} — read from the Personal Server,\n * handling 402 Payment Required.\n *\n * Access requests are created through the Vana Account access-request API; the\n * Personal Server read uses Web3Signed auth; and payment uses the DPv2 escrow\n * surface (`protocol/escrow`) — when a read returns `402`, the controller signs\n * a `GenericPayment` with the app key, settles it through the escrow gateway,\n * and retries.\n *\n * @category Direct\n * @module direct/controller\n */\n\nimport { privateKeyToAccount } from \"viem/accounts\";\nimport type { Hex } from \"viem\";\nimport type { Web3SignedSignFn } from \"../auth/web3-signed-builder\";\nimport { parseScope } from \"../protocol/scopes\";\nimport { parseScopeEntry } from \"../protocol/scope-actions\";\nimport { createEscrowGatewayClient } from \"../protocol/escrow\";\nimport { createGatewayClient } from \"../protocol/gateway\";\nimport {\n mergeWithLiveGrant,\n unionGrantScopes,\n type GrantUnionGateway,\n} from \"./grant-union\";\nimport { CONTRACTS } from \"../generated/addresses\";\nimport {\n createDefaultAccessRequestClient,\n validateAccessRequestQuestions,\n type FetchLike,\n} from \"./access-request-client\";\nimport {\n getDirectDefaultNetwork,\n getDirectEndpoints,\n getDirectNetworkChainId,\n} from \"./endpoints\";\nimport {\n AccessNotApprovedError,\n DirectConfigError,\n ScopeNotApprovedError,\n} from \"./errors\";\nimport {\n type EscrowPaymentConfig,\n type SignTypedDataFn,\n} from \"./escrow-payment\";\nimport {\n readPersonalServerData,\n type PersonalServerFetch,\n type PersonalServerTransportRetryOptions,\n} from \"./personal-server-read\";\nimport type {\n AccessRequest,\n AccessRequestClient,\n AccessRequestGrantUnion,\n AccessRequestQuestion,\n AccessRequestStatus,\n AccessRequestStatusValue,\n ApprovedDataResult,\n AppIdentity,\n DirectAppConfig,\n DirectEnv,\n DirectNetwork,\n DirectPaymentResponseMetadata,\n DirectServiceEndpoints,\n ForegroundDelivery,\n MultiScopeDataResult,\n} from \"./types\";\n\n/** Configuration for {@link createDirectDataController}. */\nexport interface DirectDataControllerConfig {\n /** Target environment. Defaults to `\"production\"`. */\n env?: DirectEnv;\n /**\n * Target Vana network for chain-aware defaults. Defaults to the selected\n * environment's historical network (`mainnet` for production, `moksha` for\n * dev). Use `network: \"moksha\"` with the default production env for\n * production app/API URLs on testnet.\n */\n network?: DirectNetwork;\n /**\n * The app private key (`0x`-prefixed, 32 bytes). Server-side only — this key\n * is the app's on-chain identity and is never exposed to the browser.\n */\n appPrivateKey?: string;\n /**\n * @deprecated Use {@link DirectDataControllerConfig.appPrivateKey}. Accepted as\n * a backwards-compatible alias; if both are set, `appPrivateKey` wins.\n */\n builderPrivateKey?: string;\n /** App identity advertised during approval. */\n app: DirectAppConfig;\n /** Data source key (e.g. `\"icloud_notes\"`). */\n source: string;\n /**\n * Grant scope entries to request. At least one required.\n *\n * Each entry is `[operation:]scope` (see `parseScopeEntry`): a bare entry\n * such as `\"icloud_notes.notes\"` requests read, and `\"write:coach.weekly\"`\n * requests write. The entries are carried through to the access request\n * verbatim and become the grant's `scopes`, so a request can mix both\n * (`[\"oura.sleep\", \"coach.weekly\", \"write:coach.weekly\"]`).\n *\n * The scope part must be a concrete `{source}.{category}[.{subcategory}]`\n * scope: this flow reads approved scopes back one by one, so wildcard\n * patterns (`chatgpt.*`, `write:chatgpt.*`) are not accepted here for\n * either operation.\n */\n scopes: string[];\n /**\n * Override the resolved service endpoints (partial). Useful for pointing at a\n * non-standard deployment.\n */\n endpoints?: Partial<DirectServiceEndpoints>;\n /**\n * Client for the Vana Account access-request API. Defaults to a client against\n * the resolved Vana Account endpoints; inject your own to point at a custom\n * deployment or to supply a test double.\n */\n accessRequestClient?: AccessRequestClient;\n /**\n * Escrow settlement config used when a Personal Server read returns `402`.\n *\n * @remarks\n * Wires the DPv2 escrow gateway (`protocol/escrow`). The controller supplies\n * the EIP-712 `signTypedData` from the app key automatically.\n *\n * When omitted (or partially omitted), the SDK derives defaults from the\n * per-network endpoints table and the contract registry:\n * - `client` defaults to a gateway client at `endpoints.escrowGatewayUrl`\n * - `escrowContract` defaults to `CONTRACTS.DataPortabilityEscrow.addresses[chainId]`\n * - `chainId` defaults to the controller's resolved chain id\n *\n * Provide this field only to override a specific default.\n */\n escrow?: Partial<DirectEscrowConfig>;\n /**\n * Gateway the controller reads the owner's live grant from when\n * `createAccessRequest` is given an `owner` (see\n * {@link DirectDataController.createAccessRequest}). Only public reads are\n * used: `listGrantsByUser` and `getBuilder`. Defaults to a gateway client at\n * `endpoints.escrowGatewayUrl`.\n */\n gateway?: GrantUnionGateway;\n /** `fetch` used by the default access-request client. Defaults to `globalThis.fetch`. */\n fetchFn?: FetchLike;\n /** `fetch` used for the Personal Server read. Defaults to `globalThis.fetch`. */\n personalServerFetch?: PersonalServerFetch;\n /**\n * Transport-retry knobs for the Personal Server read\n * ({@link PersonalServerTransportRetryOptions}). Defaults to 3 attempts with\n * exponential backoff. Retries fire only when fetch throws (the browser-PS\n * relay reconnect window), never on a received HTTP status, and never\n * re-sign a payment.\n */\n personalServerTransportRetry?: PersonalServerTransportRetryOptions;\n}\n\n/**\n * Controller-level escrow config — the {@link EscrowPaymentConfig} minus the\n * `signTypedData` and `chainId` the controller injects itself.\n */\nexport interface DirectEscrowConfig extends Omit<\n EscrowPaymentConfig,\n \"signTypedData\" | \"chainId\"\n> {\n /**\n * Chain id for the EIP-712 domain. Defaults to the controller's environment\n * (1480 for mainnet, 14800 for moksha).\n */\n chainId?: number;\n}\n\n/**\n * Server-side controller for the direct Data Portability flow.\n *\n * @typeParam T - Shape of the data returned by {@link DirectDataController.readApprovedData}.\n */\nexport interface DirectDataController {\n /** The on-chain address of the app, derived from `appPrivateKey`. */\n readonly appAddress: string;\n\n /**\n * The app's on-chain address — the address to fund and inspect in the Builder\n * activity report. Equivalent to {@link DirectDataController.appAddress}.\n *\n * @returns The app's `0x`-prefixed address.\n */\n getAppAddress(): string;\n\n /**\n * The app's full identity: its configured id/name/homepage plus the derived\n * on-chain address. Useful for telling builders which app address to fund or\n * look up.\n *\n * @returns `{ id, name, homepageUrl, address }`.\n */\n getAppIdentity(): AppIdentity;\n\n /**\n * Create an access request the user can approve.\n *\n * @param input - The post-approval return URL and optional create retry key.\n * @returns The request id, HTTPS approval URL, and — for a pending deep Direct\n * request on mobile — an optional HTTPS `mobileContinuationUrl`.\n */\n createAccessRequest(input: {\n returnUrl: string;\n /** Optional foreground mobile delivery callback. */\n foregroundDelivery?: ForegroundDelivery;\n /**\n * Derivative questions to carry on the request (1 to 4). Each question\n * asks the user's Personal Server to compute its `derivedScope` from\n * `sourceScopes` the app never reads; the derived scope must also appear\n * in the controller's `scopes` as a bare read entry. Validated eagerly\n * against the configured scopes before the request is sent. See\n * {@link AccessRequestQuestion}.\n */\n questions?: AccessRequestQuestion[];\n /**\n * The data owner, when the app already knows who will approve (for\n * example from an earlier approval). With an owner, the controller reads\n * the owner's live grant for this app and includes its still-granted\n * scopes, so the new request extends the grant instead of replacing it.\n *\n * Without an owner nothing is read: the person is often anonymous until\n * they approve, and the Vana Web approval page signs the union of the\n * live grant and the request for whoever approves.\n */\n owner?: string;\n /**\n * Merge the owner's live grant into the request scopes. Defaults to\n * `true`; has no effect without `owner`. Pass `false` to send the\n * configured scopes verbatim.\n */\n mergeLiveGrant?: boolean;\n /**\n * Scope entries the app gives up from the owner's live grant. Removed\n * from the merged scopes here and sent on the request so the approval\n * page leaves them out of its own union. Matched verbatim\n * (`write:coach.weekly` and `coach.weekly` are separate entries).\n */\n removeScopes?: string[];\n /**\n * Stable retry key when the caller retries after an uncertain response.\n * Each create without one gets its own generated key.\n */\n idempotencyKey?: string;\n }): Promise<AccessRequest>;\n\n /**\n * Fetch the current status of an access request.\n *\n * @param requestId - The `dcr_*` id from {@link DirectDataController.createAccessRequest}.\n * @returns `{ status, personalServerUrl?, grantId?, scope?, scopes? }`.\n */\n getAccessRequestStatus(requestId: string): Promise<AccessRequestStatus>;\n\n /**\n * Read the approved data from the user's Personal Server.\n *\n * @remarks\n * Resolves the request to its grant + Personal Server and performs a Web3Signed\n * read. Hides the `402 Payment Required` flow by default: if a read needs\n * payment, it signs the Personal Server's payment challenge, retries with\n * `X-PAYMENT`, and attaches shape-validated but unauthenticated\n * {@link DirectPaymentResponseMetadata} under `payment` when the Personal\n * Server returns it. After a successful read, the controller acknowledges\n * the DCR so Vana Web can close/redirect the approval tab.\n *\n * A request can approve several scopes. This reads **one** of them — `scope`\n * when given, otherwise the first approved scope. Use\n * {@link DirectDataController.readAllApprovedData} to read them all.\n *\n * Acknowledging moves the DCR to `completed`, which is terminal and no longer\n * read-ready. To read several scopes with your own loop, pass\n * `acknowledge: false` on every call but the last.\n *\n * @param input - The `dcr_*` request id, the optional `scope` to read, and an\n * optional `acknowledge` flag (default `true`).\n * @returns `{ scope, data, payment? }`.\n * @throws {@link AccessNotApprovedError} if the request is not approved.\n * @throws {@link ScopeNotApprovedError} if `scope` is not an approved scope.\n * @throws {@link PaymentRequiredError} if payment is required but unsettled.\n */\n readApprovedData<T = unknown>(input: {\n requestId: string;\n scope?: string;\n acknowledge?: boolean;\n }): Promise<ApprovedDataResult<T>>;\n\n /**\n * Read every scope the user approved on a request.\n *\n * @remarks\n * Reads the scopes in approval order, then acknowledges the DCR **once**,\n * after the last read — acknowledging earlier would move the request to\n * `completed` and make the remaining scopes unreadable.\n *\n * Each scope is a separate Personal Server read that settles its own\n * `data_access` fee from escrow, so reading N scopes costs N times a\n * single-scope read. The one-off registration fee is charged per grant, not\n * per scope.\n *\n * A scope that fails does not abort the rest: successes land in `results` and\n * failures in `errors`, because the fees for earlier scopes are already spent.\n * If any scope fails the request is left unacknowledged, so the scopes that\n * failed stay retryable — read them with `readApprovedData({ scope })` and\n * acknowledge on the last one.\n *\n * @param input - The `dcr_*` request id to read.\n * @returns `{ results, errors }`, both keyed by scope.\n * @throws {@link AccessNotApprovedError} if the request is not approved.\n */\n readAllApprovedData<T = unknown>(input: {\n requestId: string;\n }): Promise<MultiScopeDataResult<T>>;\n}\n\nfunction isHexPrivateKey(value: string): value is Hex {\n return /^0x[0-9a-fA-F]{64}$/.test(value);\n}\n\n// A DCR is read-ready only while the grant exists and the Personal Server is\n// still serving it: `approved` (durable PS) or `ready_for_read` (browser PS).\n// `completed` is terminal — the app already read and acknowledged, and the\n// browser PS may be gone — so it is deliberately excluded here.\nfunction isReadReadyStatus(status: AccessRequestStatusValue): boolean {\n return status === \"approved\" || status === \"ready_for_read\";\n}\n\n/**\n * Create a {@link DirectDataController}.\n *\n * @param config - Controller configuration (env, key, app identity, source, scopes).\n * @returns A ready-to-use controller.\n * @throws {@link DirectConfigError} when the key is missing or malformed, when\n * `scopes` is empty, or when no escrow contract can be resolved.\n * @throws InvalidScopeEntryError when a `scopes` entry does not fit the\n * `[operation:]scope` grammar (an unknown operation prefix such as `delete:`).\n * @throws ZodError when the scope part of an entry is not a valid scope.\n */\nexport function createDirectDataController(\n config: DirectDataControllerConfig,\n): DirectDataController {\n // `appPrivateKey` is the documented field; `builderPrivateKey` is a\n // deprecated alias kept for backwards compatibility.\n const privateKey = config.appPrivateKey ?? config.builderPrivateKey;\n if (!privateKey || !isHexPrivateKey(privateKey)) {\n throw new DirectConfigError(\n \"appPrivateKey must be a 0x-prefixed 32-byte hex string\",\n );\n }\n if (!config.scopes || config.scopes.length === 0) {\n throw new DirectConfigError(\"At least one scope is required\");\n }\n // Validate scopes eagerly so misconfiguration fails at construction. Each\n // element is a grant scope entry (`[operation:]scope`), so the operation\n // prefix is stripped first and only the scope part is checked against the\n // scope grammar — `write:coach.weekly` is a valid write-grant request, and\n // an unknown operation (`delete:x`) throws rather than being taken as read.\n // The entries themselves are passed through to the access request verbatim,\n // prefix included.\n for (const entry of config.scopes) {\n parseScope(parseScopeEntry(entry).scope);\n }\n\n const env: DirectEnv = config.env ?? \"production\";\n const network: DirectNetwork = config.network ?? getDirectDefaultNetwork(env);\n const defaultEndpoints = getDirectEndpoints(env);\n const chainId = config.endpoints?.chainId ?? getDirectNetworkChainId(network);\n const endpoints: DirectServiceEndpoints = {\n ...defaultEndpoints,\n ...config.endpoints,\n chainId,\n };\n\n const account = privateKeyToAccount(privateKey as Hex);\n const signMessage: Web3SignedSignFn = (message: string) =>\n account.signMessage({ message });\n // viem's account.signTypedData satisfies the structural SignTypedDataFn used\n // by the escrow GenericPayment signer.\n const signTypedData = account.signTypedData as unknown as SignTypedDataFn;\n const accessRequestClient: AccessRequestClient =\n config.accessRequestClient ??\n createDefaultAccessRequestClient({\n baseUrl: endpoints.accessRequestBaseUrl,\n approvalBaseUrl: endpoints.approvalAppBaseUrl,\n env,\n fetchFn: config.fetchFn,\n appAddress: account.address,\n signMessage,\n });\n\n // Build the escrow payment config, defaulting from the per-network endpoints\n // table and the contract registry when `config.escrow` is omitted or partial.\n const escrowChainId = config.escrow?.chainId ?? chainId;\n const defaultEscrowContract =\n CONTRACTS.DataPortabilityEscrow.addresses[\n escrowChainId as keyof typeof CONTRACTS.DataPortabilityEscrow.addresses\n ] ?? undefined;\n if (!config.escrow?.escrowContract && !defaultEscrowContract) {\n throw new DirectConfigError(\n `No DataPortabilityEscrow address found in the registry for chainId ${escrowChainId}. ` +\n `Provide an explicit escrow.escrowContract in the controller config.`,\n );\n }\n const escrow: EscrowPaymentConfig = {\n client:\n config.escrow?.client ??\n createEscrowGatewayClient(endpoints.escrowGatewayUrl),\n escrowContract:\n config.escrow?.escrowContract ?? (defaultEscrowContract as `0x${string}`),\n chainId: escrowChainId,\n nonceSource: config.escrow?.nonceSource,\n signTypedData,\n };\n\n let gateway: GrantUnionGateway | undefined = config.gateway;\n\n // Read the owner's live grant and union it with the configured scopes. A\n // failed read never blocks the request: it goes out with the configured\n // scopes, and the approval page's own union still keeps the live grant.\n async function resolveGrantUnion(\n owner: string | undefined,\n options: { merge: boolean; removeScopes: string[] },\n ): Promise<AccessRequestGrantUnion> {\n // Without a merged live grant the request carries the configured scopes\n // verbatim (duplicates and order included), so bodies and their\n // signatures match SDKs that predate the grant union.\n const requestedOnly = {\n ...unionGrantScopes([], config.scopes, options.removeScopes),\n scopes: [...config.scopes],\n };\n if (!options.merge) {\n return { status: \"disabled\", ...requestedOnly };\n }\n if (!owner) {\n return { status: \"owner_unknown\", ...requestedOnly };\n }\n gateway ??= createGatewayClient(endpoints.escrowGatewayUrl);\n try {\n return await mergeWithLiveGrant({\n gateway,\n owner,\n appAddress: account.address,\n scopes: config.scopes,\n removeScopes: options.removeScopes,\n });\n } catch (error) {\n return {\n status: \"unavailable\",\n reason: error instanceof Error ? error.message : String(error),\n ...requestedOnly,\n };\n }\n }\n\n return {\n appAddress: account.address,\n\n getAppAddress(): string {\n return account.address;\n },\n\n getAppIdentity(): AppIdentity {\n return {\n id: config.app.id,\n name: config.app.name,\n homepageUrl: config.app.homepageUrl,\n address: account.address,\n };\n },\n\n async createAccessRequest(input): Promise<AccessRequest> {\n // Fail fast even with an injected client: a malformed question would be\n // rejected by the access-request service anyway, after a signed round\n // trip.\n if (input.questions !== undefined) {\n validateAccessRequestQuestions(input.questions, config.scopes);\n }\n const removeScopes = input.removeScopes ?? [];\n for (const entry of removeScopes) {\n parseScopeEntry(entry);\n }\n const grantUnion = await resolveGrantUnion(input.owner, {\n merge: input.mergeLiveGrant !== false,\n removeScopes,\n });\n const created = await accessRequestClient.createAccessRequest({\n appAddress: account.address,\n app: config.app,\n source: config.source,\n scopes: grantUnion.scopes,\n ...(removeScopes.length > 0 ? { removeScopes } : {}),\n returnUrl: input.returnUrl,\n network,\n ...(input.foregroundDelivery !== undefined\n ? { foregroundDelivery: input.foregroundDelivery }\n : {}),\n ...(input.questions !== undefined\n ? { questions: input.questions }\n : {}),\n ...(input.idempotencyKey !== undefined\n ? { idempotencyKey: input.idempotencyKey }\n : {}),\n });\n return { ...created, scopes: grantUnion.scopes, grantUnion };\n },\n\n async getAccessRequestStatus(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n return accessRequestClient.getAccessRequestStatus(requestId);\n },\n\n async readApprovedData<T = unknown>(input: {\n requestId: string;\n scope?: string;\n acknowledge?: boolean;\n }): Promise<ApprovedDataResult<T>> {\n const status = await requireReadReady(input.requestId);\n const scope = resolveRequestedScope(status, input.scope);\n\n const result = await readScope<T>(status, scope);\n if (input.acknowledge !== false) {\n await acknowledgeQuietly(input.requestId);\n }\n return result;\n },\n\n async readAllApprovedData<T = unknown>(input: {\n requestId: string;\n }): Promise<MultiScopeDataResult<T>> {\n const status = await requireReadReady(input.requestId);\n const scopes = approvedScopes(status);\n\n const results: Record<string, ApprovedDataResult<T>> = {};\n const errors: Record<string, Error> = {};\n // Sequential, not parallel: each read settles its own escrow payment and\n // the default nonce source is process-local, so concurrent reads would\n // race on the payment nonce.\n for (const scope of scopes) {\n try {\n results[scope] = await readScope<T>(status, scope);\n } catch (error) {\n errors[scope] =\n error instanceof Error ? error : new Error(String(error));\n }\n }\n\n // Acknowledge only after the last read, and only if every scope read —\n // acking moves the DCR to `completed`, which is terminal and no longer\n // read-ready, so acking on a partial failure would make the scope that\n // failed impossible to retry.\n if (Object.keys(errors).length === 0) {\n await acknowledgeQuietly(input.requestId);\n }\n\n return { results, errors };\n },\n };\n\n async function requireReadReady(\n requestId: string,\n ): Promise<AccessRequestStatus> {\n const status = await accessRequestClient.getAccessRequestStatus(requestId);\n // `scope` and `scopes` are both optional on the public status type, and a\n // client may return either one — require at least one approved scope rather\n // than the singular field specifically.\n if (\n !isReadReadyStatus(status.status) ||\n !status.personalServerUrl ||\n !status.grantId ||\n approvedScopes(status).length === 0\n ) {\n throw new AccessNotApprovedError(\n \"Request is not approved or is missing grantId/scope/personalServerUrl\",\n {\n requestId,\n status: status.status,\n hasPersonalServerUrl: Boolean(status.personalServerUrl),\n hasGrantId: Boolean(status.grantId),\n hasScope: approvedScopes(status).length > 0,\n },\n );\n }\n return status;\n }\n\n /** Approved scopes in approval order, falling back to the single `scope`. */\n function approvedScopes(status: AccessRequestStatus): string[] {\n if (status.scopes && status.scopes.length > 0) return status.scopes;\n return status.scope ? [status.scope] : [];\n }\n\n /**\n * Resolve which scope to read. Rejects an unapproved scope up front so it\n * never reaches the Personal Server and never settles a fee.\n */\n function resolveRequestedScope(\n status: AccessRequestStatus,\n requested?: string,\n ): string {\n const scopes = approvedScopes(status);\n if (requested === undefined) return scopes[0];\n if (!scopes.includes(requested)) {\n throw new ScopeNotApprovedError(\n `Scope \"${requested}\" is not approved on this request`,\n { requestedScope: requested, approvedScopes: scopes },\n );\n }\n return requested;\n }\n\n async function readScope<T>(\n status: AccessRequestStatus,\n scope: string,\n ): Promise<ApprovedDataResult<T>> {\n const result = await readPersonalServerData({\n personalServerUrl: status.personalServerUrl as string,\n scope,\n grantId: status.grantId as string,\n payerAddress: account.address,\n signMessage,\n escrow,\n fetchFn: config.personalServerFetch,\n transportRetry: config.personalServerTransportRetry,\n });\n return { scope, data: result.data as T, payment: result.payment };\n }\n\n async function acknowledgeQuietly(requestId: string): Promise<void> {\n try {\n await accessRequestClient.acknowledgeRead?.(requestId);\n } catch {\n // The read already succeeded; ack only drives Vana Web completion UX.\n }\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var grant_union_exports = {};
20
+ __export(grant_union_exports, {
21
+ findLiveGrant: () => findLiveGrant,
22
+ liveGrantScopes: () => liveGrantScopes,
23
+ mergeWithLiveGrant: () => mergeWithLiveGrant,
24
+ unionGrantScopes: () => unionGrantScopes
25
+ });
26
+ module.exports = __toCommonJS(grant_union_exports);
27
+ var import_scopes = require("../protocol/scopes");
28
+ var import_scope_actions = require("../protocol/scope-actions");
29
+ var import_errors = require("./errors.cjs");
30
+ function dedupe(entries) {
31
+ return [...new Set(entries)];
32
+ }
33
+ function isCarryable(entry) {
34
+ try {
35
+ (0, import_scopes.parseScope)((0, import_scope_actions.parseScopeEntry)(entry).scope);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+ function unionGrantScopes(liveScopes, scopes, removeScopes = []) {
42
+ const remove = new Set(removeScopes);
43
+ const conflicting = scopes.filter((entry) => remove.has(entry));
44
+ if (conflicting.length > 0) {
45
+ throw new import_errors.DirectConfigError(
46
+ `${conflicting.join(", ")} cannot be both requested and removed. Drop it from scopes or from removeScopes.`,
47
+ { conflicting }
48
+ );
49
+ }
50
+ const live = dedupe(liveScopes);
51
+ const requested = dedupe(scopes);
52
+ const liveSet = new Set(live);
53
+ const removed = live.filter((entry) => remove.has(entry));
54
+ const remaining = live.filter((entry) => !remove.has(entry));
55
+ const notCarried = remaining.filter((entry) => !isCarryable(entry));
56
+ const keptAll = remaining.filter(isCarryable);
57
+ const added = requested.filter((entry) => !liveSet.has(entry));
58
+ return {
59
+ scopes: dedupe([...keptAll, ...added]),
60
+ kept: keptAll,
61
+ added,
62
+ removed,
63
+ notCarried,
64
+ liveScopes: live
65
+ };
66
+ }
67
+ function isActiveGrant(grant) {
68
+ return !grant.revokedAt && !grant.expired;
69
+ }
70
+ function sameHex(a, b) {
71
+ return a.toLowerCase() === b.toLowerCase();
72
+ }
73
+ async function findLiveGrant(gateway, owner, granteeId) {
74
+ const grants = await gateway.listGrantsByUser(owner);
75
+ const matching = grants.filter(
76
+ (grant) => sameHex(grant.granteeId, granteeId) && isActiveGrant(grant)
77
+ ).sort((a, b) => {
78
+ const av = BigInt(a.grantVersion || "0");
79
+ const bv = BigInt(b.grantVersion || "0");
80
+ return av === bv ? 0 : av > bv ? -1 : 1;
81
+ });
82
+ return matching[0] ?? null;
83
+ }
84
+ async function liveGrantScopes(gateway, owner, granteeId) {
85
+ const grant = await findLiveGrant(gateway, owner, granteeId);
86
+ return grant ? [...grant.scopes] : [];
87
+ }
88
+ async function resolveGranteeId(input) {
89
+ if (input.granteeId) {
90
+ return input.granteeId;
91
+ }
92
+ if (!input.appAddress) {
93
+ throw new import_errors.DirectConfigError(
94
+ "mergeWithLiveGrant needs granteeId or appAddress to find the app's grant."
95
+ );
96
+ }
97
+ if (!input.gateway.getBuilder) {
98
+ throw new import_errors.DirectConfigError(
99
+ "mergeWithLiveGrant needs gateway.getBuilder to resolve appAddress to a granteeId. Pass granteeId instead."
100
+ );
101
+ }
102
+ const builder = await input.gateway.getBuilder(input.appAddress);
103
+ return builder?.id ?? null;
104
+ }
105
+ async function mergeWithLiveGrant(input) {
106
+ const removeScopes = input.removeScopes ?? [];
107
+ unionGrantScopes([], input.scopes, removeScopes);
108
+ const granteeId = await resolveGranteeId(input);
109
+ const grant = granteeId ? await findLiveGrant(input.gateway, input.owner, granteeId) : null;
110
+ if (!grant) {
111
+ return {
112
+ status: "no_live_grant",
113
+ ...unionGrantScopes([], input.scopes, removeScopes)
114
+ };
115
+ }
116
+ return {
117
+ status: "merged",
118
+ grantId: grant.id,
119
+ ...unionGrantScopes(grant.scopes, input.scopes, removeScopes)
120
+ };
121
+ }
122
+ // Annotate the CommonJS export names for ESM import in node:
123
+ 0 && (module.exports = {
124
+ findLiveGrant,
125
+ liveGrantScopes,
126
+ mergeWithLiveGrant,
127
+ unionGrantScopes
128
+ });
129
+ //# sourceMappingURL=grant-union.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/direct/grant-union.ts"],"sourcesContent":["/**\n * Grant-union helpers: extend an app's live grant instead of replacing it.\n *\n * @remarks\n * The gateway keeps **one** grant per (owner, app). A new approval does not\n * add a second grant next to the first: it bumps `grantVersion` and replaces\n * the grant's `scopes` with whatever the new request carried. An app that\n * already holds `oura.sleep` and now asks only for `whoop.recovery` would,\n * once the owner approves, hold `whoop.recovery` and nothing else.\n *\n * These helpers build the request scopes an app actually wants: every scope\n * the live grant still covers, plus the new ones, minus any the app is\n * explicitly giving up (`removeScopes`).\n *\n * The owner is often unknown when a request is created (the person is\n * anonymous until they approve). In that case there is no grant to read, and\n * the Vana Web approval page signs the union of the live grant and the\n * request for the owner who approves, honoring the request's\n * `removeScopes`. Use {@link mergeWithLiveGrant} directly when the app does\n * know the owner, for example from an earlier approval.\n *\n * @category Direct\n * @module direct/grant-union\n */\n\nimport type { GatewayClient, GrantListItem } from \"../protocol/gateway\";\nimport { parseScope } from \"../protocol/scopes\";\nimport { parseScopeEntry } from \"../protocol/scope-actions\";\nimport { DirectConfigError } from \"./errors\";\n\n/** The slice of the gateway client the grant-union helpers read through. */\nexport type GrantUnionGateway = Pick<GatewayClient, \"listGrantsByUser\"> &\n Partial<Pick<GatewayClient, \"getBuilder\">>;\n\n/** Input to {@link mergeWithLiveGrant}. */\nexport interface MergeWithLiveGrantInput {\n /** Gateway to read the live grant from (only public reads are used). */\n gateway: GrantUnionGateway;\n /** The data owner (grantor) whose live grant to extend. */\n owner: string;\n /**\n * The app's `granteeId` (its bytes32 builder id). Pass this or\n * `appAddress`; when only `appAddress` is given the builder id is resolved\n * with `gateway.getBuilder(appAddress)`.\n */\n granteeId?: string;\n /** The app's on-chain address, used to resolve `granteeId` when absent. */\n appAddress?: string;\n /** Scope entries the new request asks for, verbatim. */\n scopes: readonly string[];\n /**\n * Scope entries the app gives up. Matched verbatim against the live grant\n * (`write:coach.weekly` removes only the write entry; the bare\n * `coach.weekly` removes only the read). An entry may not appear in both\n * `scopes` and `removeScopes`.\n */\n removeScopes?: readonly string[];\n}\n\n/** How {@link mergeWithLiveGrant} arrived at its scopes. */\nexport type GrantUnionStatus =\n /** A live grant was found and merged. */\n | \"merged\"\n /** The owner holds no active grant for this app; nothing to keep. */\n | \"no_live_grant\";\n\n/** Result of {@link mergeWithLiveGrant} and {@link unionGrantScopes}. */\nexport interface GrantUnion {\n /** The scope entries to send on the new request, deduplicated. */\n scopes: string[];\n /** Live grant entries carried over into `scopes`. */\n kept: string[];\n /** Requested entries the live grant did not already cover. */\n added: string[];\n /** Live grant entries dropped because they were in `removeScopes`. */\n removed: string[];\n /**\n * Live grant entries that a data connection request cannot carry (a\n * wildcard such as `chatgpt.*`, or an operation this SDK does not know),\n * so they are left out of `scopes`. The approval page's own union still\n * keeps them on the signed grant unless they are removed there.\n */\n notCarried: string[];\n /** The live grant's scope entries, verbatim (empty without a live grant). */\n liveScopes: string[];\n}\n\n/** Result of {@link mergeWithLiveGrant}. */\nexport interface MergeWithLiveGrantResult extends GrantUnion {\n status: GrantUnionStatus;\n /** The live grant's id, when there is one. */\n grantId?: string;\n}\n\nfunction dedupe(entries: readonly string[]): string[] {\n return [...new Set(entries)];\n}\n\nfunction isCarryable(entry: string): boolean {\n try {\n parseScope(parseScopeEntry(entry).scope);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Combine a live grant's scopes with a new request's scopes.\n *\n * @remarks\n * Pure: no network. Order is live entries first (in grant order), then the\n * newly requested ones, so a diff against the previous grant reads naturally.\n *\n * @param liveScopes - The live grant's scope entries (empty when none).\n * @param scopes - The scope entries the new request asks for.\n * @param removeScopes - Live entries the app gives up.\n * @returns The merged scopes plus the kept/added/removed breakdown.\n * @throws {DirectConfigError} When an entry is both requested and removed.\n */\nexport function unionGrantScopes(\n liveScopes: readonly string[],\n scopes: readonly string[],\n removeScopes: readonly string[] = [],\n): GrantUnion {\n const remove = new Set(removeScopes);\n const conflicting = scopes.filter((entry) => remove.has(entry));\n if (conflicting.length > 0) {\n throw new DirectConfigError(\n `${conflicting.join(\", \")} cannot be both requested and removed. Drop it from scopes or from removeScopes.`,\n { conflicting },\n );\n }\n const live = dedupe(liveScopes);\n const requested = dedupe(scopes);\n const liveSet = new Set(live);\n\n const removed = live.filter((entry) => remove.has(entry));\n const remaining = live.filter((entry) => !remove.has(entry));\n const notCarried = remaining.filter((entry) => !isCarryable(entry));\n // A requested entry the grant already held counts as kept: the app keeps\n // it, it does not newly gain it.\n const keptAll = remaining.filter(isCarryable);\n const added = requested.filter((entry) => !liveSet.has(entry));\n\n return {\n scopes: dedupe([...keptAll, ...added]),\n kept: keptAll,\n added,\n removed,\n notCarried,\n liveScopes: live,\n };\n}\n\nfunction isActiveGrant(grant: GrantListItem): boolean {\n return !grant.revokedAt && !grant.expired;\n}\n\nfunction sameHex(a: string, b: string): boolean {\n return a.toLowerCase() === b.toLowerCase();\n}\n\n/**\n * Find the owner's active grant for one app.\n *\n * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).\n * @param owner - The grantor address.\n * @param granteeId - The app's bytes32 builder id.\n * @returns The active grant, or `null` when there is none.\n */\nexport async function findLiveGrant(\n gateway: Pick<GatewayClient, \"listGrantsByUser\">,\n owner: string,\n granteeId: string,\n): Promise<GrantListItem | null> {\n const grants = await gateway.listGrantsByUser(owner);\n // One grant per (owner, app) on the gateway; if a stale list ever shows\n // more, the newest version wins.\n const matching = grants\n .filter(\n (grant) => sameHex(grant.granteeId, granteeId) && isActiveGrant(grant),\n )\n .sort((a, b) => {\n const av = BigInt(a.grantVersion || \"0\");\n const bv = BigInt(b.grantVersion || \"0\");\n return av === bv ? 0 : av > bv ? -1 : 1;\n });\n return matching[0] ?? null;\n}\n\n/**\n * The scope entries an owner's live grant for one app still covers.\n *\n * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).\n * @param owner - The grantor address.\n * @param granteeId - The app's bytes32 builder id.\n * @returns The live grant's scope entries, verbatim; empty when the owner\n * holds no active (unrevoked, unexpired) grant for the app.\n */\nexport async function liveGrantScopes(\n gateway: Pick<GatewayClient, \"listGrantsByUser\">,\n owner: string,\n granteeId: string,\n): Promise<string[]> {\n const grant = await findLiveGrant(gateway, owner, granteeId);\n return grant ? [...grant.scopes] : [];\n}\n\nasync function resolveGranteeId(\n input: MergeWithLiveGrantInput,\n): Promise<string | null> {\n if (input.granteeId) {\n return input.granteeId;\n }\n if (!input.appAddress) {\n throw new DirectConfigError(\n \"mergeWithLiveGrant needs granteeId or appAddress to find the app's grant.\",\n );\n }\n if (!input.gateway.getBuilder) {\n throw new DirectConfigError(\n \"mergeWithLiveGrant needs gateway.getBuilder to resolve appAddress to a granteeId. Pass granteeId instead.\",\n );\n }\n const builder = await input.gateway.getBuilder(input.appAddress);\n return builder?.id ?? null;\n}\n\n/**\n * Build the scopes for a new request so it extends the owner's live grant\n * for this app rather than replacing it.\n *\n * @example\n * ```ts\n * const union = await mergeWithLiveGrant({\n * gateway: createGatewayClient(\"https://dp-rpc.vana.org\"),\n * owner: \"0xOwner...\",\n * appAddress: controller.appAddress,\n * scopes: [\"whoop.recovery\"],\n * removeScopes: [\"oura.sleep\"],\n * });\n * // union.scopes: live scopes minus oura.sleep, plus whoop.recovery\n * ```\n *\n * @param input - Gateway, owner, app identity, requested and removed scopes.\n * @returns The merged scopes, the breakdown, and whether a live grant existed.\n * @throws {DirectConfigError} When an entry is both requested and removed, or\n * the app identity cannot be resolved from the input.\n */\nexport async function mergeWithLiveGrant(\n input: MergeWithLiveGrantInput,\n): Promise<MergeWithLiveGrantResult> {\n const removeScopes = input.removeScopes ?? [];\n // Validate the conflict before any network call.\n unionGrantScopes([], input.scopes, removeScopes);\n const granteeId = await resolveGranteeId(input);\n const grant = granteeId\n ? await findLiveGrant(input.gateway, input.owner, granteeId)\n : null;\n if (!grant) {\n return {\n status: \"no_live_grant\",\n ...unionGrantScopes([], input.scopes, removeScopes),\n };\n }\n return {\n status: \"merged\",\n grantId: grant.id,\n ...unionGrantScopes(grant.scopes, input.scopes, removeScopes),\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Grant-union helpers: extend an app's live grant instead of replacing it.
3
+ *
4
+ * @remarks
5
+ * The gateway keeps **one** grant per (owner, app). A new approval does not
6
+ * add a second grant next to the first: it bumps `grantVersion` and replaces
7
+ * the grant's `scopes` with whatever the new request carried. An app that
8
+ * already holds `oura.sleep` and now asks only for `whoop.recovery` would,
9
+ * once the owner approves, hold `whoop.recovery` and nothing else.
10
+ *
11
+ * These helpers build the request scopes an app actually wants: every scope
12
+ * the live grant still covers, plus the new ones, minus any the app is
13
+ * explicitly giving up (`removeScopes`).
14
+ *
15
+ * The owner is often unknown when a request is created (the person is
16
+ * anonymous until they approve). In that case there is no grant to read, and
17
+ * the Vana Web approval page signs the union of the live grant and the
18
+ * request for the owner who approves, honoring the request's
19
+ * `removeScopes`. Use {@link mergeWithLiveGrant} directly when the app does
20
+ * know the owner, for example from an earlier approval.
21
+ *
22
+ * @category Direct
23
+ * @module direct/grant-union
24
+ */
25
+ import type { GatewayClient, GrantListItem } from "../protocol/gateway.js";
26
+ /** The slice of the gateway client the grant-union helpers read through. */
27
+ export type GrantUnionGateway = Pick<GatewayClient, "listGrantsByUser"> & Partial<Pick<GatewayClient, "getBuilder">>;
28
+ /** Input to {@link mergeWithLiveGrant}. */
29
+ export interface MergeWithLiveGrantInput {
30
+ /** Gateway to read the live grant from (only public reads are used). */
31
+ gateway: GrantUnionGateway;
32
+ /** The data owner (grantor) whose live grant to extend. */
33
+ owner: string;
34
+ /**
35
+ * The app's `granteeId` (its bytes32 builder id). Pass this or
36
+ * `appAddress`; when only `appAddress` is given the builder id is resolved
37
+ * with `gateway.getBuilder(appAddress)`.
38
+ */
39
+ granteeId?: string;
40
+ /** The app's on-chain address, used to resolve `granteeId` when absent. */
41
+ appAddress?: string;
42
+ /** Scope entries the new request asks for, verbatim. */
43
+ scopes: readonly string[];
44
+ /**
45
+ * Scope entries the app gives up. Matched verbatim against the live grant
46
+ * (`write:coach.weekly` removes only the write entry; the bare
47
+ * `coach.weekly` removes only the read). An entry may not appear in both
48
+ * `scopes` and `removeScopes`.
49
+ */
50
+ removeScopes?: readonly string[];
51
+ }
52
+ /** How {@link mergeWithLiveGrant} arrived at its scopes. */
53
+ export type GrantUnionStatus =
54
+ /** A live grant was found and merged. */
55
+ "merged"
56
+ /** The owner holds no active grant for this app; nothing to keep. */
57
+ | "no_live_grant";
58
+ /** Result of {@link mergeWithLiveGrant} and {@link unionGrantScopes}. */
59
+ export interface GrantUnion {
60
+ /** The scope entries to send on the new request, deduplicated. */
61
+ scopes: string[];
62
+ /** Live grant entries carried over into `scopes`. */
63
+ kept: string[];
64
+ /** Requested entries the live grant did not already cover. */
65
+ added: string[];
66
+ /** Live grant entries dropped because they were in `removeScopes`. */
67
+ removed: string[];
68
+ /**
69
+ * Live grant entries that a data connection request cannot carry (a
70
+ * wildcard such as `chatgpt.*`, or an operation this SDK does not know),
71
+ * so they are left out of `scopes`. The approval page's own union still
72
+ * keeps them on the signed grant unless they are removed there.
73
+ */
74
+ notCarried: string[];
75
+ /** The live grant's scope entries, verbatim (empty without a live grant). */
76
+ liveScopes: string[];
77
+ }
78
+ /** Result of {@link mergeWithLiveGrant}. */
79
+ export interface MergeWithLiveGrantResult extends GrantUnion {
80
+ status: GrantUnionStatus;
81
+ /** The live grant's id, when there is one. */
82
+ grantId?: string;
83
+ }
84
+ /**
85
+ * Combine a live grant's scopes with a new request's scopes.
86
+ *
87
+ * @remarks
88
+ * Pure: no network. Order is live entries first (in grant order), then the
89
+ * newly requested ones, so a diff against the previous grant reads naturally.
90
+ *
91
+ * @param liveScopes - The live grant's scope entries (empty when none).
92
+ * @param scopes - The scope entries the new request asks for.
93
+ * @param removeScopes - Live entries the app gives up.
94
+ * @returns The merged scopes plus the kept/added/removed breakdown.
95
+ * @throws {DirectConfigError} When an entry is both requested and removed.
96
+ */
97
+ export declare function unionGrantScopes(liveScopes: readonly string[], scopes: readonly string[], removeScopes?: readonly string[]): GrantUnion;
98
+ /**
99
+ * Find the owner's active grant for one app.
100
+ *
101
+ * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).
102
+ * @param owner - The grantor address.
103
+ * @param granteeId - The app's bytes32 builder id.
104
+ * @returns The active grant, or `null` when there is none.
105
+ */
106
+ export declare function findLiveGrant(gateway: Pick<GatewayClient, "listGrantsByUser">, owner: string, granteeId: string): Promise<GrantListItem | null>;
107
+ /**
108
+ * The scope entries an owner's live grant for one app still covers.
109
+ *
110
+ * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).
111
+ * @param owner - The grantor address.
112
+ * @param granteeId - The app's bytes32 builder id.
113
+ * @returns The live grant's scope entries, verbatim; empty when the owner
114
+ * holds no active (unrevoked, unexpired) grant for the app.
115
+ */
116
+ export declare function liveGrantScopes(gateway: Pick<GatewayClient, "listGrantsByUser">, owner: string, granteeId: string): Promise<string[]>;
117
+ /**
118
+ * Build the scopes for a new request so it extends the owner's live grant
119
+ * for this app rather than replacing it.
120
+ *
121
+ * @example
122
+ * ```ts
123
+ * const union = await mergeWithLiveGrant({
124
+ * gateway: createGatewayClient("https://dp-rpc.vana.org"),
125
+ * owner: "0xOwner...",
126
+ * appAddress: controller.appAddress,
127
+ * scopes: ["whoop.recovery"],
128
+ * removeScopes: ["oura.sleep"],
129
+ * });
130
+ * // union.scopes: live scopes minus oura.sleep, plus whoop.recovery
131
+ * ```
132
+ *
133
+ * @param input - Gateway, owner, app identity, requested and removed scopes.
134
+ * @returns The merged scopes, the breakdown, and whether a live grant existed.
135
+ * @throws {DirectConfigError} When an entry is both requested and removed, or
136
+ * the app identity cannot be resolved from the input.
137
+ */
138
+ export declare function mergeWithLiveGrant(input: MergeWithLiveGrantInput): Promise<MergeWithLiveGrantResult>;
@@ -0,0 +1,102 @@
1
+ import { parseScope } from "../protocol/scopes.js";
2
+ import { parseScopeEntry } from "../protocol/scope-actions.js";
3
+ import { DirectConfigError } from "./errors.js";
4
+ function dedupe(entries) {
5
+ return [...new Set(entries)];
6
+ }
7
+ function isCarryable(entry) {
8
+ try {
9
+ parseScope(parseScopeEntry(entry).scope);
10
+ return true;
11
+ } catch {
12
+ return false;
13
+ }
14
+ }
15
+ function unionGrantScopes(liveScopes, scopes, removeScopes = []) {
16
+ const remove = new Set(removeScopes);
17
+ const conflicting = scopes.filter((entry) => remove.has(entry));
18
+ if (conflicting.length > 0) {
19
+ throw new DirectConfigError(
20
+ `${conflicting.join(", ")} cannot be both requested and removed. Drop it from scopes or from removeScopes.`,
21
+ { conflicting }
22
+ );
23
+ }
24
+ const live = dedupe(liveScopes);
25
+ const requested = dedupe(scopes);
26
+ const liveSet = new Set(live);
27
+ const removed = live.filter((entry) => remove.has(entry));
28
+ const remaining = live.filter((entry) => !remove.has(entry));
29
+ const notCarried = remaining.filter((entry) => !isCarryable(entry));
30
+ const keptAll = remaining.filter(isCarryable);
31
+ const added = requested.filter((entry) => !liveSet.has(entry));
32
+ return {
33
+ scopes: dedupe([...keptAll, ...added]),
34
+ kept: keptAll,
35
+ added,
36
+ removed,
37
+ notCarried,
38
+ liveScopes: live
39
+ };
40
+ }
41
+ function isActiveGrant(grant) {
42
+ return !grant.revokedAt && !grant.expired;
43
+ }
44
+ function sameHex(a, b) {
45
+ return a.toLowerCase() === b.toLowerCase();
46
+ }
47
+ async function findLiveGrant(gateway, owner, granteeId) {
48
+ const grants = await gateway.listGrantsByUser(owner);
49
+ const matching = grants.filter(
50
+ (grant) => sameHex(grant.granteeId, granteeId) && isActiveGrant(grant)
51
+ ).sort((a, b) => {
52
+ const av = BigInt(a.grantVersion || "0");
53
+ const bv = BigInt(b.grantVersion || "0");
54
+ return av === bv ? 0 : av > bv ? -1 : 1;
55
+ });
56
+ return matching[0] ?? null;
57
+ }
58
+ async function liveGrantScopes(gateway, owner, granteeId) {
59
+ const grant = await findLiveGrant(gateway, owner, granteeId);
60
+ return grant ? [...grant.scopes] : [];
61
+ }
62
+ async function resolveGranteeId(input) {
63
+ if (input.granteeId) {
64
+ return input.granteeId;
65
+ }
66
+ if (!input.appAddress) {
67
+ throw new DirectConfigError(
68
+ "mergeWithLiveGrant needs granteeId or appAddress to find the app's grant."
69
+ );
70
+ }
71
+ if (!input.gateway.getBuilder) {
72
+ throw new DirectConfigError(
73
+ "mergeWithLiveGrant needs gateway.getBuilder to resolve appAddress to a granteeId. Pass granteeId instead."
74
+ );
75
+ }
76
+ const builder = await input.gateway.getBuilder(input.appAddress);
77
+ return builder?.id ?? null;
78
+ }
79
+ async function mergeWithLiveGrant(input) {
80
+ const removeScopes = input.removeScopes ?? [];
81
+ unionGrantScopes([], input.scopes, removeScopes);
82
+ const granteeId = await resolveGranteeId(input);
83
+ const grant = granteeId ? await findLiveGrant(input.gateway, input.owner, granteeId) : null;
84
+ if (!grant) {
85
+ return {
86
+ status: "no_live_grant",
87
+ ...unionGrantScopes([], input.scopes, removeScopes)
88
+ };
89
+ }
90
+ return {
91
+ status: "merged",
92
+ grantId: grant.id,
93
+ ...unionGrantScopes(grant.scopes, input.scopes, removeScopes)
94
+ };
95
+ }
96
+ export {
97
+ findLiveGrant,
98
+ liveGrantScopes,
99
+ mergeWithLiveGrant,
100
+ unionGrantScopes
101
+ };
102
+ //# sourceMappingURL=grant-union.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/direct/grant-union.ts"],"sourcesContent":["/**\n * Grant-union helpers: extend an app's live grant instead of replacing it.\n *\n * @remarks\n * The gateway keeps **one** grant per (owner, app). A new approval does not\n * add a second grant next to the first: it bumps `grantVersion` and replaces\n * the grant's `scopes` with whatever the new request carried. An app that\n * already holds `oura.sleep` and now asks only for `whoop.recovery` would,\n * once the owner approves, hold `whoop.recovery` and nothing else.\n *\n * These helpers build the request scopes an app actually wants: every scope\n * the live grant still covers, plus the new ones, minus any the app is\n * explicitly giving up (`removeScopes`).\n *\n * The owner is often unknown when a request is created (the person is\n * anonymous until they approve). In that case there is no grant to read, and\n * the Vana Web approval page signs the union of the live grant and the\n * request for the owner who approves, honoring the request's\n * `removeScopes`. Use {@link mergeWithLiveGrant} directly when the app does\n * know the owner, for example from an earlier approval.\n *\n * @category Direct\n * @module direct/grant-union\n */\n\nimport type { GatewayClient, GrantListItem } from \"../protocol/gateway\";\nimport { parseScope } from \"../protocol/scopes\";\nimport { parseScopeEntry } from \"../protocol/scope-actions\";\nimport { DirectConfigError } from \"./errors\";\n\n/** The slice of the gateway client the grant-union helpers read through. */\nexport type GrantUnionGateway = Pick<GatewayClient, \"listGrantsByUser\"> &\n Partial<Pick<GatewayClient, \"getBuilder\">>;\n\n/** Input to {@link mergeWithLiveGrant}. */\nexport interface MergeWithLiveGrantInput {\n /** Gateway to read the live grant from (only public reads are used). */\n gateway: GrantUnionGateway;\n /** The data owner (grantor) whose live grant to extend. */\n owner: string;\n /**\n * The app's `granteeId` (its bytes32 builder id). Pass this or\n * `appAddress`; when only `appAddress` is given the builder id is resolved\n * with `gateway.getBuilder(appAddress)`.\n */\n granteeId?: string;\n /** The app's on-chain address, used to resolve `granteeId` when absent. */\n appAddress?: string;\n /** Scope entries the new request asks for, verbatim. */\n scopes: readonly string[];\n /**\n * Scope entries the app gives up. Matched verbatim against the live grant\n * (`write:coach.weekly` removes only the write entry; the bare\n * `coach.weekly` removes only the read). An entry may not appear in both\n * `scopes` and `removeScopes`.\n */\n removeScopes?: readonly string[];\n}\n\n/** How {@link mergeWithLiveGrant} arrived at its scopes. */\nexport type GrantUnionStatus =\n /** A live grant was found and merged. */\n | \"merged\"\n /** The owner holds no active grant for this app; nothing to keep. */\n | \"no_live_grant\";\n\n/** Result of {@link mergeWithLiveGrant} and {@link unionGrantScopes}. */\nexport interface GrantUnion {\n /** The scope entries to send on the new request, deduplicated. */\n scopes: string[];\n /** Live grant entries carried over into `scopes`. */\n kept: string[];\n /** Requested entries the live grant did not already cover. */\n added: string[];\n /** Live grant entries dropped because they were in `removeScopes`. */\n removed: string[];\n /**\n * Live grant entries that a data connection request cannot carry (a\n * wildcard such as `chatgpt.*`, or an operation this SDK does not know),\n * so they are left out of `scopes`. The approval page's own union still\n * keeps them on the signed grant unless they are removed there.\n */\n notCarried: string[];\n /** The live grant's scope entries, verbatim (empty without a live grant). */\n liveScopes: string[];\n}\n\n/** Result of {@link mergeWithLiveGrant}. */\nexport interface MergeWithLiveGrantResult extends GrantUnion {\n status: GrantUnionStatus;\n /** The live grant's id, when there is one. */\n grantId?: string;\n}\n\nfunction dedupe(entries: readonly string[]): string[] {\n return [...new Set(entries)];\n}\n\nfunction isCarryable(entry: string): boolean {\n try {\n parseScope(parseScopeEntry(entry).scope);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Combine a live grant's scopes with a new request's scopes.\n *\n * @remarks\n * Pure: no network. Order is live entries first (in grant order), then the\n * newly requested ones, so a diff against the previous grant reads naturally.\n *\n * @param liveScopes - The live grant's scope entries (empty when none).\n * @param scopes - The scope entries the new request asks for.\n * @param removeScopes - Live entries the app gives up.\n * @returns The merged scopes plus the kept/added/removed breakdown.\n * @throws {DirectConfigError} When an entry is both requested and removed.\n */\nexport function unionGrantScopes(\n liveScopes: readonly string[],\n scopes: readonly string[],\n removeScopes: readonly string[] = [],\n): GrantUnion {\n const remove = new Set(removeScopes);\n const conflicting = scopes.filter((entry) => remove.has(entry));\n if (conflicting.length > 0) {\n throw new DirectConfigError(\n `${conflicting.join(\", \")} cannot be both requested and removed. Drop it from scopes or from removeScopes.`,\n { conflicting },\n );\n }\n const live = dedupe(liveScopes);\n const requested = dedupe(scopes);\n const liveSet = new Set(live);\n\n const removed = live.filter((entry) => remove.has(entry));\n const remaining = live.filter((entry) => !remove.has(entry));\n const notCarried = remaining.filter((entry) => !isCarryable(entry));\n // A requested entry the grant already held counts as kept: the app keeps\n // it, it does not newly gain it.\n const keptAll = remaining.filter(isCarryable);\n const added = requested.filter((entry) => !liveSet.has(entry));\n\n return {\n scopes: dedupe([...keptAll, ...added]),\n kept: keptAll,\n added,\n removed,\n notCarried,\n liveScopes: live,\n };\n}\n\nfunction isActiveGrant(grant: GrantListItem): boolean {\n return !grant.revokedAt && !grant.expired;\n}\n\nfunction sameHex(a: string, b: string): boolean {\n return a.toLowerCase() === b.toLowerCase();\n}\n\n/**\n * Find the owner's active grant for one app.\n *\n * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).\n * @param owner - The grantor address.\n * @param granteeId - The app's bytes32 builder id.\n * @returns The active grant, or `null` when there is none.\n */\nexport async function findLiveGrant(\n gateway: Pick<GatewayClient, \"listGrantsByUser\">,\n owner: string,\n granteeId: string,\n): Promise<GrantListItem | null> {\n const grants = await gateway.listGrantsByUser(owner);\n // One grant per (owner, app) on the gateway; if a stale list ever shows\n // more, the newest version wins.\n const matching = grants\n .filter(\n (grant) => sameHex(grant.granteeId, granteeId) && isActiveGrant(grant),\n )\n .sort((a, b) => {\n const av = BigInt(a.grantVersion || \"0\");\n const bv = BigInt(b.grantVersion || \"0\");\n return av === bv ? 0 : av > bv ? -1 : 1;\n });\n return matching[0] ?? null;\n}\n\n/**\n * The scope entries an owner's live grant for one app still covers.\n *\n * @param gateway - Gateway to read from (`GET /v1/grants?user=<owner>`).\n * @param owner - The grantor address.\n * @param granteeId - The app's bytes32 builder id.\n * @returns The live grant's scope entries, verbatim; empty when the owner\n * holds no active (unrevoked, unexpired) grant for the app.\n */\nexport async function liveGrantScopes(\n gateway: Pick<GatewayClient, \"listGrantsByUser\">,\n owner: string,\n granteeId: string,\n): Promise<string[]> {\n const grant = await findLiveGrant(gateway, owner, granteeId);\n return grant ? [...grant.scopes] : [];\n}\n\nasync function resolveGranteeId(\n input: MergeWithLiveGrantInput,\n): Promise<string | null> {\n if (input.granteeId) {\n return input.granteeId;\n }\n if (!input.appAddress) {\n throw new DirectConfigError(\n \"mergeWithLiveGrant needs granteeId or appAddress to find the app's grant.\",\n );\n }\n if (!input.gateway.getBuilder) {\n throw new DirectConfigError(\n \"mergeWithLiveGrant needs gateway.getBuilder to resolve appAddress to a granteeId. Pass granteeId instead.\",\n );\n }\n const builder = await input.gateway.getBuilder(input.appAddress);\n return builder?.id ?? null;\n}\n\n/**\n * Build the scopes for a new request so it extends the owner's live grant\n * for this app rather than replacing it.\n *\n * @example\n * ```ts\n * const union = await mergeWithLiveGrant({\n * gateway: createGatewayClient(\"https://dp-rpc.vana.org\"),\n * owner: \"0xOwner...\",\n * appAddress: controller.appAddress,\n * scopes: [\"whoop.recovery\"],\n * removeScopes: [\"oura.sleep\"],\n * });\n * // union.scopes: live scopes minus oura.sleep, plus whoop.recovery\n * ```\n *\n * @param input - Gateway, owner, app identity, requested and removed scopes.\n * @returns The merged scopes, the breakdown, and whether a live grant existed.\n * @throws {DirectConfigError} When an entry is both requested and removed, or\n * the app identity cannot be resolved from the input.\n */\nexport async function mergeWithLiveGrant(\n input: MergeWithLiveGrantInput,\n): Promise<MergeWithLiveGrantResult> {\n const removeScopes = input.removeScopes ?? [];\n // Validate the conflict before any network call.\n unionGrantScopes([], input.scopes, removeScopes);\n const granteeId = await resolveGranteeId(input);\n const grant = granteeId\n ? await findLiveGrant(input.gateway, input.owner, granteeId)\n : null;\n if (!grant) {\n return {\n status: \"no_live_grant\",\n ...unionGrantScopes([], input.scopes, removeScopes),\n };\n }\n return {\n status: \"merged\",\n grantId: grant.id,\n ...unionGrantScopes(grant.scopes, input.scopes, removeScopes),\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1 @@
1
+ export {};