{"version":3,"sources":["../../src/protocol/scope-actions.ts"],"sourcesContent":["import { scopeMatchesPattern } from \"./scopes\";\n\n/**\n * Grant scope-entry grammar.\n *\n * A signed grant carries `scopes: string[]`. Each entry is\n * `[operation:]scope` - an optional lowercase ASCII operation prefix before\n * the first `:`, then a scope pattern (`*`, `{prefix}.*`, or an exact scope).\n * A missing prefix means read. `write:notes.entries` authorizes writing\n * `notes.entries` and nothing else; `notes.entries` authorizes reading it.\n *\n * The string form is the wire and storage detail: it is what the grantor\n * signs (EIP-712 `GrantRegistration.scopes`) and what the gateway stores\n * verbatim. The Personal Server is the sole interpreter, and this module is\n * the SDK-side mirror of that interpretation. Builders and consent UIs should\n * work with the grouped `{ scope, actions }` view (see\n * {@link grantPermissions}) and never construct or parse the strings by hand.\n *\n * Matching rules, pinned by the Personal Server policy\n * (personal-server-ts `packages/core/src/policy/data-write.ts` and\n * `data-read.ts`):\n * - the operation is compared exactly, case-sensitively;\n * - wildcards apply to the scope part only, via {@link scopeMatchesPattern};\n * - an entry whose operation is not recognised never authorizes anything.\n *   The parser fails closed on it (throws) rather than treating it as read;\n *   the matcher ({@link hasAction}) skips it, which is how the Personal\n *   Server treats an entry it does not understand.\n */\n\n/** Operations the grammar defines today, in canonical (output) order. */\nexport const SCOPE_ACTIONS = [\"read\", \"write\"] as const;\n\n/** An operation a grant entry can authorize over a scope. */\nexport type ScopeAction = (typeof SCOPE_ACTIONS)[number];\n\n/** One grant entry, split into its operation and scope pattern. */\nexport interface ParsedScopeEntry {\n  scope: string;\n  action: ScopeAction;\n}\n\n/**\n * The grouped view of a grant's scope entries: one row per scope pattern\n * with every operation the grant authorizes over it.\n */\nexport interface GrantPermission {\n  scope: string;\n  actions: ScopeAction[];\n}\n\n/**\n * Thrown when a scope entry does not fit the grammar - an unknown or\n * malformed operation prefix, or an empty scope part.\n */\nexport class InvalidScopeEntryError extends Error {\n  /** The offending entry, verbatim (unknown because it may not be a string). */\n  readonly entry: unknown;\n\n  constructor(entry: unknown, reason: string) {\n    super(`Invalid scope entry ${describeValue(entry)}: ${reason}`);\n    this.name = \"InvalidScopeEntryError\";\n    this.entry = entry;\n  }\n}\n\nconst OPERATION_SEPARATOR = \":\";\n\n// Render an untrusted value for an error message without ever throwing:\n// String() and JSON.stringify() both defer to the value's own toString /\n// toJSON, which a hostile JSON body can make throw.\nfunction describeValue(value: unknown): string {\n  if (typeof value === \"string\") return JSON.stringify(value);\n  if (value === null) return \"null\";\n  return `[${typeof value}]`;\n}\n\n// The only operation that is ever written out. Read has no prefix, and\n// `read:` is NOT an alias for it: the Personal Server's read policy matches\n// entries verbatim, so a `read:x` entry would authorize nothing there, and\n// the parser must reject it for the same reason.\nconst OPERATION_BY_PREFIX: Readonly<Record<string, ScopeAction>> = {\n  write: \"write\",\n};\n\nfunction assertScopePart(entry: string, scope: string): void {\n  if (scope.length === 0) {\n    throw new InvalidScopeEntryError(entry, \"scope part is empty\");\n  }\n  if (scope.includes(OPERATION_SEPARATOR)) {\n    throw new InvalidScopeEntryError(\n      entry,\n      `scope part must not contain \"${OPERATION_SEPARATOR}\"`,\n    );\n  }\n}\n\n/**\n * Split one grant scope entry into its operation and scope pattern.\n *\n * - `notes.entries` parses as `{ scope: \"notes.entries\", action: \"read\" }`\n * - `write:notes.*` parses as `{ scope: \"notes.*\", action: \"write\" }`\n *\n * Fails closed: a non-string entry, or an entry whose operation prefix is\n * not recognised (including\n * `read:`, any uppercase or non-ASCII prefix, or a wildcard in the operation\n * position) throws {@link InvalidScopeEntryError} and is never treated as a\n * read entry. An empty scope part (`write:`) throws as well.\n *\n * @param entry - A single element of a grant's `scopes` array.\n * @returns The operation and the scope pattern it applies to.\n * @throws InvalidScopeEntryError when the entry does not fit the grammar.\n */\nexport function parseScopeEntry(entry: string): ParsedScopeEntry {\n  // Grant bodies arrive from the network; a non-string element is a grammar\n  // violation like any other, not a TypeError from indexOf.\n  const raw: unknown = entry;\n  if (typeof raw !== \"string\") {\n    throw new InvalidScopeEntryError(raw, \"entry must be a string\");\n  }\n  const separatorIndex = entry.indexOf(OPERATION_SEPARATOR);\n  if (separatorIndex === -1) {\n    assertScopePart(entry, entry);\n    return { scope: entry, action: \"read\" };\n  }\n\n  const prefix = entry.slice(0, separatorIndex);\n  const scope = entry.slice(separatorIndex + 1);\n  const action = Object.hasOwn(OPERATION_BY_PREFIX, prefix)\n    ? OPERATION_BY_PREFIX[prefix]\n    : undefined;\n  if (action === undefined) {\n    throw new InvalidScopeEntryError(\n      entry,\n      `unknown operation \"${prefix}\" (known: ${Object.keys(OPERATION_BY_PREFIX).join(\", \")}; read has no prefix)`,\n    );\n  }\n  assertScopePart(entry, scope);\n  return { scope, action };\n}\n\n/**\n * Inverse of {@link parseScopeEntry}: render one operation over one scope\n * pattern as a grant scope entry. Read has no prefix.\n *\n * @param parsed - The operation and scope pattern to encode.\n * @returns The wire-form entry, e.g. `write:notes.entries` or `notes.entries`.\n * @throws InvalidScopeEntryError when the action is unknown or the scope part\n * is empty or contains `:`.\n */\nexport function formatScopeEntry(parsed: ParsedScopeEntry): string {\n  const { scope, action } = parsed;\n  assertScopePart(scope, scope);\n  if (action === \"read\") return scope;\n  // Looked up rather than hard-coded so an action can never be emitted\n  // without a prefix the parser accepts (and JS callers passing an unknown\n  // action fail closed instead of producing a read entry).\n  const prefix = Object.entries(OPERATION_BY_PREFIX).find(\n    ([, candidate]) => candidate === action,\n  )?.[0];\n  if (prefix === undefined) {\n    throw new InvalidScopeEntryError(\n      scope,\n      `unknown action ${describeValue(action)} (known: ${SCOPE_ACTIONS.join(\", \")})`,\n    );\n  }\n  return `${prefix}${OPERATION_SEPARATOR}${scope}`;\n}\n\nfunction compareScopes(a: string, b: string): number {\n  // Plain code-unit order: locale-independent, so the grouping is identical\n  // on every runtime.\n  if (a < b) return -1;\n  if (a > b) return 1;\n  return 0;\n}\n\nfunction sortActions(actions: Iterable<ScopeAction>): ScopeAction[] {\n  const present = new Set(actions);\n  return SCOPE_ACTIONS.filter((action) => present.has(action));\n}\n\n/**\n * Group a grant's scope entries into one `{ scope, actions }` row per scope\n * pattern - the view builders and consent UIs should render instead of the\n * raw strings.\n *\n * The result is canonical: rows are ordered by scope (code-unit order),\n * actions within a row follow {@link SCOPE_ACTIONS} order, and neither rows\n * nor actions repeat, whatever order or duplication the input had.\n *\n * Fails closed: if any entry does not fit the grammar this throws\n * {@link InvalidScopeEntryError} rather than silently dropping it, so a grant\n * carrying an operation this SDK does not know is never shown as narrower\n * than it is.\n *\n * @param scopes - A grant's `scopes` array, verbatim.\n * @returns The grouped, canonically ordered permissions.\n * @throws InvalidScopeEntryError when any entry does not fit the grammar.\n */\nexport function grantPermissions(scopes: readonly string[]): GrantPermission[] {\n  const byScope = new Map<string, Set<ScopeAction>>();\n  for (const entry of scopes) {\n    const { scope, action } = parseScopeEntry(entry);\n    let actions = byScope.get(scope);\n    if (actions === undefined) {\n      actions = new Set<ScopeAction>();\n      byScope.set(scope, actions);\n    }\n    actions.add(action);\n  }\n  return [...byScope.keys()].sort(compareScopes).map((scope) => ({\n    scope,\n    actions: sortActions(byScope.get(scope) ?? []),\n  }));\n}\n\n/**\n * Inverse of {@link grantPermissions}: flatten grouped permissions back into\n * the `string[]` form a grant is signed with.\n *\n * Output is canonical (scopes in code-unit order, read before write, no\n * duplicates), so `permissionsToScopes(grantPermissions(scopes))` is the\n * canonical form of `scopes`, and `grantPermissions(permissionsToScopes(p))`\n * is the canonical form of `p`. Rows with no actions contribute nothing; a\n * row with an action the grammar does not define throws rather than being\n * dropped.\n *\n * @param permissions - Grouped permissions, in any order, possibly repeating\n * a scope.\n * @returns The scope entries, one per (scope, action) pair.\n * @throws InvalidScopeEntryError when a scope or action does not fit the\n * grammar.\n */\nexport function permissionsToScopes(\n  permissions: readonly GrantPermission[],\n): string[] {\n  const byScope = new Map<string, Set<ScopeAction>>();\n  for (const { scope, actions } of permissions) {\n    let merged = byScope.get(scope);\n    if (merged === undefined) {\n      merged = new Set<ScopeAction>();\n      byScope.set(scope, merged);\n    }\n    for (const action of actions) {\n      if (!(SCOPE_ACTIONS as readonly string[]).includes(action)) {\n        throw new InvalidScopeEntryError(\n          scope,\n          `unknown action ${describeValue(action)} (known: ${SCOPE_ACTIONS.join(\", \")})`,\n        );\n      }\n      merged.add(action);\n    }\n  }\n  const entries: string[] = [];\n  for (const scope of [...byScope.keys()].sort(compareScopes)) {\n    for (const action of sortActions(byScope.get(scope) ?? [])) {\n      entries.push(formatScopeEntry({ scope, action }));\n    }\n  }\n  return entries;\n}\n\n/**\n * Does this grant authorize `action` over `scope`?\n *\n * The scope part is matched with the SDK's scope wildcard matcher\n * ({@link scopeMatchesPattern}: `*`, `{prefix}.*`, or exact), the action\n * exactly. Entries that do not fit the grammar are skipped - they authorize\n * nothing, which is exactly how the Personal Server treats them - so a grant\n * that carries an operation this SDK does not know still answers correctly\n * for the operations it does.\n *\n * @param scopes - A grant's `scopes` array, verbatim.\n * @param scope - The concrete scope being requested. Never prefixed: a value\n * containing `:` is not a scope id and yields `false`.\n * @param action - The operation being requested.\n * @returns `true` if some entry grants `action` over a pattern covering\n * `scope`.\n */\nexport function hasAction(\n  scopes: readonly string[],\n  scope: string,\n  action: ScopeAction,\n): boolean {\n  // A requested scope is a concrete scope id and never carries a prefix; the\n  // Personal Server rejects anything else with ScopeSchema before it ever\n  // reaches its matcher, so answer the same way here instead of letting\n  // `write:x` fall through to a `*` entry.\n  if (scope.includes(OPERATION_SEPARATOR)) return false;\n  for (const entry of scopes) {\n    let parsed: ParsedScopeEntry;\n    try {\n      parsed = parseScopeEntry(entry);\n    } catch (error) {\n      if (error instanceof InvalidScopeEntryError) continue;\n      throw error;\n    }\n    if (parsed.action === action && scopeMatchesPattern(scope, parsed.scope)) {\n      return true;\n    }\n  }\n  return false;\n}\n\n/**\n * {@link grantPermissions} for a grant record read back from the gateway:\n * returns `undefined` instead of throwing when the scope list carries an\n * entry this SDK version cannot interpret, so a grant with a newer operation\n * still loads (with `scopes` intact) rather than failing the whole read.\n *\n * @param scopes - A grant's `scopes` array, verbatim.\n * @returns The grouped permissions, or `undefined` if any entry is\n * uninterpretable.\n */\nexport function tryGrantPermissions(\n  scopes: readonly string[],\n): GrantPermission[] | undefined {\n  try {\n    return grantPermissions(scopes);\n  } catch (error) {\n    if (error instanceof InvalidScopeEntryError) return undefined;\n    throw error;\n  }\n}\n"],"mappings":"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","names":[]}