{"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":";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0BA,oBAA2B;AAC3B,2BAAgC;AAChC,oBAAkC;AAkElC,SAAS,OAAO,SAAsC;AACpD,SAAO,CAAC,GAAG,IAAI,IAAI,OAAO,CAAC;AAC7B;AAEA,SAAS,YAAY,OAAwB;AAC3C,MAAI;AACF,sCAAW,sCAAgB,KAAK,EAAE,KAAK;AACvC,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAeO,SAAS,iBACd,YACA,QACA,eAAkC,CAAC,GACvB;AACZ,QAAM,SAAS,IAAI,IAAI,YAAY;AACnC,QAAM,cAAc,OAAO,OAAO,CAAC,UAAU,OAAO,IAAI,KAAK,CAAC;AAC9D,MAAI,YAAY,SAAS,GAAG;AAC1B,UAAM,IAAI;AAAA,MACR,GAAG,YAAY,KAAK,IAAI,CAAC;AAAA,MACzB,EAAE,YAAY;AAAA,IAChB;AAAA,EACF;AACA,QAAM,OAAO,OAAO,UAAU;AAC9B,QAAM,YAAY,OAAO,MAAM;AAC/B,QAAM,UAAU,IAAI,IAAI,IAAI;AAE5B,QAAM,UAAU,KAAK,OAAO,CAAC,UAAU,OAAO,IAAI,KAAK,CAAC;AACxD,QAAM,YAAY,KAAK,OAAO,CAAC,UAAU,CAAC,OAAO,IAAI,KAAK,CAAC;AAC3D,QAAM,aAAa,UAAU,OAAO,CAAC,UAAU,CAAC,YAAY,KAAK,CAAC;AAGlE,QAAM,UAAU,UAAU,OAAO,WAAW;AAC5C,QAAM,QAAQ,UAAU,OAAO,CAAC,UAAU,CAAC,QAAQ,IAAI,KAAK,CAAC;AAE7D,SAAO;AAAA,IACL,QAAQ,OAAO,CAAC,GAAG,SAAS,GAAG,KAAK,CAAC;AAAA,IACrC,MAAM;AAAA,IACN;AAAA,IACA;AAAA,IACA;AAAA,IACA,YAAY;AAAA,EACd;AACF;AAEA,SAAS,cAAc,OAA+B;AACpD,SAAO,CAAC,MAAM,aAAa,CAAC,MAAM;AACpC;AAEA,SAAS,QAAQ,GAAW,GAAoB;AAC9C,SAAO,EAAE,YAAY,MAAM,EAAE,YAAY;AAC3C;AAUA,eAAsB,cACpB,SACA,OACA,WAC+B;AAC/B,QAAM,SAAS,MAAM,QAAQ,iBAAiB,KAAK;AAGnD,QAAM,WAAW,OACd;AAAA,IACC,CAAC,UAAU,QAAQ,MAAM,WAAW,SAAS,KAAK,cAAc,KAAK;AAAA,EACvE,EACC,KAAK,CAAC,GAAG,MAAM;AACd,UAAM,KAAK,OAAO,EAAE,gBAAgB,GAAG;AACvC,UAAM,KAAK,OAAO,EAAE,gBAAgB,GAAG;AACvC,WAAO,OAAO,KAAK,IAAI,KAAK,KAAK,KAAK;AAAA,EACxC,CAAC;AACH,SAAO,SAAS,CAAC,KAAK;AACxB;AAWA,eAAsB,gBACpB,SACA,OACA,WACmB;AACnB,QAAM,QAAQ,MAAM,cAAc,SAAS,OAAO,SAAS;AAC3D,SAAO,QAAQ,CAAC,GAAG,MAAM,MAAM,IAAI,CAAC;AACtC;AAEA,eAAe,iBACb,OACwB;AACxB,MAAI,MAAM,WAAW;AACnB,WAAO,MAAM;AAAA,EACf;AACA,MAAI,CAAC,MAAM,YAAY;AACrB,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,MAAI,CAAC,MAAM,QAAQ,YAAY;AAC7B,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACA,QAAM,UAAU,MAAM,MAAM,QAAQ,WAAW,MAAM,UAAU;AAC/D,SAAO,SAAS,MAAM;AACxB;AAuBA,eAAsB,mBACpB,OACmC;AACnC,QAAM,eAAe,MAAM,gBAAgB,CAAC;AAE5C,mBAAiB,CAAC,GAAG,MAAM,QAAQ,YAAY;AAC/C,QAAM,YAAY,MAAM,iBAAiB,KAAK;AAC9C,QAAM,QAAQ,YACV,MAAM,cAAc,MAAM,SAAS,MAAM,OAAO,SAAS,IACzD;AACJ,MAAI,CAAC,OAAO;AACV,WAAO;AAAA,MACL,QAAQ;AAAA,MACR,GAAG,iBAAiB,CAAC,GAAG,MAAM,QAAQ,YAAY;AAAA,IACpD;AAAA,EACF;AACA,SAAO;AAAA,IACL,QAAQ;AAAA,IACR,SAAS,MAAM;AAAA,IACf,GAAG,iBAAiB,MAAM,QAAQ,MAAM,QAAQ,YAAY;AAAA,EAC9D;AACF;","names":[]}