Class: AgentAdmit::TokensClient

Inherits:
Object
  • Object
show all
Defined in:
lib/agentadmit/tokens_client.rb

Overview

TokensClient — issue, exchange, and revoke connection tokens via the AgentAdmit hosted service.

Constant Summary collapse

UNSET =

Sentinel for issue_token's duration_seconds: leave the field out of the request entirely, so AgentAdmit applies its default (30 days). Pass nil instead for an until-revoked connection (explicit JSON null).

Object.new.freeze
PURPOSE_MAX_LENGTH =

Maximum length of a declared purpose (matches the hosted API contract).

300
USER_INTENT_MAX_LENGTH =

Maximum length of a user-declared intent (matches the hosted API contract: optional string, 1..300 characters).

300

Instance Method Summary collapse

Constructor Details

#initialize(config = nil) ⇒ TokensClient

Returns a new instance of TokensClient.



25
26
27
28
# File 'lib/agentadmit/tokens_client.rb', line 25

def initialize(config = nil)
  @config = config || AgentAdmit.configuration || Config.new
  @config.validate_api_key!
end

Instance Method Details

#exchange(connection_token, agent_label: nil, agent_id: nil) ⇒ Hash

Exchange a single-use connection token for an access token. Calls POST /api/v1/exchange — unauthenticated by design: the connection token itself is the credential, so the operator API key is NOT sent.

Parameters:

  • connection_token (String)

    the ag_ct_… connection token

  • agent_label (String, nil) (defaults to: nil)

    human-readable agent name

  • agent_id (String, nil) (defaults to: nil)

    agent identifier

Returns:

  • (Hash)

    the exchange response — "access_token" is the ag_at_… token

Raises:



117
118
119
120
121
122
123
# File 'lib/agentadmit/tokens_client.rb', line 117

def exchange(connection_token, agent_label: nil, agent_id: nil)
  body = { "token" => connection_token }
  body["agent_label"] = agent_label if agent_label
  body["agent_id"] = agent_id if agent_id

  post("/api/v1/exchange", body, authenticated: false, op: "exchange")
end

#issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil, user_intent: nil, presence: nil) ⇒ Hash

Issue a connection token for one of your users. Calls POST /api/v1/apps/app_id/token.

The duration is tri-state:

  • omit the argument — field omitted; AgentAdmit applies its default (30 days)
  • nil — explicit JSON null; the connection lasts until revoked
  • Integer — explicit duration in seconds (60–31536000)

Parameters:

  • user_id (String)

    your app's identifier for the user

  • scopes (Array<String>)

    scopes the connection grants

  • role (String, nil) (defaults to: nil)

    the user's role on the connection

  • duration_seconds (Integer, nil, UNSET) (defaults to: UNSET)

    see above

  • purpose (String, nil) (defaults to: nil)

    declared purpose: the user-facing reason recorded on the grant at the consent moment. Review-time record only, never an enforcement input; authorization decisions ride scopes, connection status, and consent. Max 300 characters; omitted from the request when nil.

  • user_intent (String, nil) (defaults to: nil)

    user-declared intent: the user's OWN words, typed at the consent moment (distinct from purpose, which is the app's words). Optional, 1-300 characters. Validated like purpose: a non-String, non-nil value or a string over 300 characters raises ArgumentError before any request is sent — silently discarding the user's typed words would be data loss. Empty/whitespace-only strings normalize to nil and are omitted. Like purpose, it is a review-time record, never an enforcement input.

  • presence (AppAttestedPresence, nil) (defaults to: nil)

    app-attested ceremony fact: set it AFTER verifying and consuming your app's own fresh, purpose-bound WebAuthn/passkey attestation for this mint. Forwarded as presence true, uv: true, method, verified_at and stored provenance-marked "app:"; omitted when nil (omitting the field is the only way to say "no ceremony").

Returns:

  • (Hash)

    the issue response — "token" is the self-describing ag_ct_… connection token to hand to the user's agent

Raises:

  • (ArgumentError)

    if purpose exceeds 300 characters, if user_intent is a non-String (other than nil) or exceeds 300 characters, or if presence is neither nil nor an AppAttestedPresence

  • (IntrospectionError)

    if issuance fails



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/agentadmit/tokens_client.rb', line 70

def issue_token(user_id:, scopes:, role: nil, duration_seconds: UNSET, purpose: nil,
                user_intent: nil, presence: nil)
  if purpose && purpose.length > PURPOSE_MAX_LENGTH
    raise ArgumentError, "purpose must be at most #{PURPOSE_MAX_LENGTH} characters"
  end

  # User-declared intent is validated like purpose: reject out-of-contract
  # values before any request rather than silently discarding the user's
  # typed words (data loss). Empty/whitespace-only normalizes to nil-omit.
  unless user_intent.nil? || user_intent.is_a?(String)
    raise ArgumentError, "user_intent must be a String or nil"
  end
  if user_intent && user_intent.length > USER_INTENT_MAX_LENGTH
    raise ArgumentError, "user_intent must be at most #{USER_INTENT_MAX_LENGTH} characters"
  end
  user_intent = nil if user_intent && user_intent.strip.empty?

  # Presence is typed-only: a raw Hash is rejected so the wire contract
  # (literal-true verified/uv, offset-carrying verified_at) stays owned
  # by AppAttestedPresence, never hand-rolled at call sites.
  unless presence.nil? || presence.is_a?(AppAttestedPresence)
    raise ArgumentError, "presence must be an AgentAdmit::AppAttestedPresence or nil"
  end

  body = { "user_id" => user_id, "scopes" => scopes }
  body["role"] = role if role
  body["purpose"] = purpose if purpose
  body["user_intent"] = user_intent if user_intent
  body["presence"] = presence.to_wire if presence
  # Tri-state: the UNSET sentinel omits the key entirely; nil survives
  # JSON.generate as explicit JSON null (no compact, no nil-guard).
  body["duration_seconds"] = duration_seconds unless duration_seconds.equal?(UNSET)

  post("/api/v1/apps/#{@config.app_id}/token", body, authenticated: true, op: "issue_token")
end

#revoke(connection_id, reason: nil) ⇒ Hash

Revoke a connection (and its access tokens). Calls POST /api/v1/revoke.

Parameters:

  • connection_id (String)

    the connection to revoke

  • reason (String, nil) (defaults to: nil)

    optional human-readable reason

Returns:

  • (Hash)

    the revoke response — { "ok" => true, ... }

Raises:



134
135
136
137
138
139
# File 'lib/agentadmit/tokens_client.rb', line 134

def revoke(connection_id, reason: nil)
  body = { "connection_id" => connection_id }
  body["reason"] = reason if reason

  post("/api/v1/revoke", body, authenticated: true, op: "revoke")
end