Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# FeedMob CLI (`fm`)

`fm` is the command-line interface for FeedMob services. It manages isolated
credentials for Pixel and Time Off, verifies identity, and issues read-only
credentials for Pixel, Time Off, and Femini, verifies authentication, and issues read-only
API requests — with stable JSON output designed for scripts and automation.

```text
Expand All @@ -15,6 +15,10 @@ fm time-off auth login [--token-stdin]
fm time-off auth status
fm time-off auth logout
fm time-off request get <path>
fm femini auth login [--token-stdin]
fm femini auth status
fm femini auth logout
fm femini request get <path>
```

## Installation
Expand Down Expand Up @@ -64,6 +68,7 @@ Each service keeps its own credential, resolved in this order:
| --- | --- | --- | --- | --- |
| Pixel | `FEEDMOB_PIXEL_TOKEN` | `https://feedmob-pixel-dashboard.feedmob.com/rails` | `GET /api/v1/cli/me` | Revokes remotely, deletes local store |
| Time Off | `FEEDMOB_TIME_OFF_TOKEN` | `https://time-off.feedmob.com` | `GET /api/v1/me` | Deletes local store only |
| Femini | `FEEDMOB_FEMINI_TOKEN` | `https://assistant.feedmob.ai` | `GET /clients.json?name_cont=__feedmob_cli_auth_probe__` | Deletes local store only |

```sh
# Interactive, hidden input; the token never appears in argv or history
Expand All @@ -75,7 +80,7 @@ printf '%s' "$FEEDMOB_PIXEL_TOKEN" | fm pixel auth login --token-stdin
```

Endpoints can be overridden with `FEEDMOB_PIXEL_BASE_URL` and
`FEEDMOB_TIME_OFF_BASE_URL`. Overrides must use HTTPS; plain HTTP is only
`FEEDMOB_TIME_OFF_BASE_URL`, and `FEEDMOB_FEMINI_BASE_URL`. Overrides must use HTTPS; plain HTTP is only
accepted for loopback addresses (`localhost`, `127.0.0.1`, `::1`) together
with an explicit `FEEDMOB_ALLOW_INSECURE_HTTP=1` — never set that variable
in shared or production environments.
Expand All @@ -94,6 +99,12 @@ fm pixel request get /api/v1/cli/me
absolute URLs and `//host` paths are rejected so tokens can never leak to an
unconfigured host. Only GET requests are exposed.

Femini uses the documented Bearer Token authentication: obtain the token from
the Profile menu in Femini, then run `fm femini auth login`. Femini does not
document a dedicated identity endpoint, so login/status use a filtered,
read-only clients request as an authentication probe and never print its
response. Femini API tokens have no documented prefix requirement.

### JSON output

With `--json` (accepted anywhere on the command line), every command prints
Expand Down
1 change: 1 addition & 0 deletions lib/feedmob/cli.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ def start(argv = ARGV, stdout: $stdout, stderr: $stderr)
Commands:
doctor Check configuration and service authentication
version Print the fm version
femini [SUBCOMMAND] Work with Femini
pixel [SUBCOMMAND] Work with FeedMob Pixel
time-off [SUBCOMMAND] Work with FeedMob Time Off
HELP
Expand Down
32 changes: 25 additions & 7 deletions lib/feedmob/cli/commands/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,31 +17,31 @@ def call(token_stdin: false, **)
source = runtime.credentials.storage_source

output.success(
{
authentication_payload(
service: service.name,
authenticated: true,
source:,
identity: response.data
},
response:
),
message: "Authenticated with #{service.label}; credential saved in #{runtime.credentials.storage_label}."
)
end
end

class AuthStatus < Base
desc 'Show the authenticated identity for a service'
desc 'Check an authenticated service credential'

def call(**)
credential = credential!(service)
response = runtime.client(service).request(method: :get, path: service.identity_path, token: credential.value)

output.success(
{
authentication_payload(
service: service.name,
authenticated: true,
source: credential.source,
identity: response.data
},
response:
),
message: "Authenticated with #{service.label} using #{credential.source}."
)
end
Expand Down Expand Up @@ -139,6 +139,24 @@ class TimeOffAuthLogout < AuthLogout

def service_name = 'time-off'
end

class FeminiAuthLogin < AuthLogin
desc 'Verify and securely save a Femini bearer token'

def service_name = 'femini'
end

class FeminiAuthStatus < AuthStatus
desc 'Check the configured Femini bearer token'

def service_name = 'femini'
end

class FeminiAuthLogout < AuthLogout
desc 'Remove the local Femini bearer token'

def service_name = 'femini'
end
end
end
end
6 changes: 6 additions & 0 deletions lib/feedmob/cli/commands/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ def credential!(service)
].join(' ')
)
end

def authentication_payload(service:, authenticated:, source:, response:)
payload = { service:, authenticated:, source: }
payload[:identity] = response.data if self.service.identity_response
payload
end
end
end
end
Expand Down
7 changes: 4 additions & 3 deletions lib/feedmob/cli/commands/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,13 @@ def check(service)
return missing(service) if credential.source == 'missing'

response = runtime.client(service).request(method: :get, path: service.identity_path, token: credential.value)
{
payload = {
service: service.name,
authenticated: true,
credential_source: credential.source,
identity: response.data
credential_source: credential.source
}
payload[:identity] = response.data if service.identity_response
payload
rescue Error => e
{
service: service.name,
Expand Down
6 changes: 6 additions & 0 deletions lib/feedmob/cli/commands/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ class TimeOffRequestGet < RequestGet

def service_name = 'time-off'
end

class FeminiRequestGet < RequestGet
desc 'Perform an authenticated GET request against the Femini API'

def service_name = 'femini'
end
end
end
end
3 changes: 2 additions & 1 deletion lib/feedmob/cli/credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ def storage_label
end

def validate_token!(service, token)
return if token.to_s.start_with?(service.token_prefix)
prefix = service.token_prefix
return if prefix.nil? || token.to_s.start_with?(prefix)

raise Error.new(
code: 'invalid_token_format',
Expand Down
10 changes: 10 additions & 0 deletions lib/feedmob/cli/registry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ module Commands
request.register 'get', TimeOffRequestGet
end
end
register 'femini' do |femini|
femini.register 'auth' do |auth|
auth.register 'login', FeminiAuthLogin
auth.register 'status', FeminiAuthStatus
auth.register 'logout', FeminiAuthLogout
end
femini.register 'request' do |request|
request.register 'get', FeminiRequestGet
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/feedmob/cli/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module CLI
:token_env,
:token_prefix,
:identity_path,
:identity_response,
:revoke_path,
:keychain_service
)
Expand Down
14 changes: 14 additions & 0 deletions lib/feedmob/cli/services.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module Services
token_env: 'FEEDMOB_PIXEL_TOKEN',
token_prefix: 'fmpat_',
identity_path: '/api/v1/cli/me',
identity_response: true,
revoke_path: '/api/v1/cli/token',
keychain_service: 'com.feedmob.fm.pixel'
},
Expand All @@ -25,8 +26,20 @@ module Services
token_env: 'FEEDMOB_TIME_OFF_TOKEN',
token_prefix: 'fmtopat_',
identity_path: '/api/v1/me',
identity_response: true,
revoke_path: nil,
keychain_service: 'com.feedmob.fm.time-off'
},
'femini' => {
label: 'Femini',
base_url: 'https://assistant.feedmob.ai',
base_url_env: 'FEEDMOB_FEMINI_BASE_URL',
token_env: 'FEEDMOB_FEMINI_TOKEN',
token_prefix: nil,
identity_path: '/clients.json?name_cont=__feedmob_cli_auth_probe__',
identity_response: false,
revoke_path: nil,
keychain_service: 'com.feedmob.fm.femini'
}
}.freeze

Expand All @@ -45,6 +58,7 @@ def fetch(name, env: ENV)
token_env: definition.fetch(:token_env),
token_prefix: definition.fetch(:token_prefix),
identity_path: definition.fetch(:identity_path),
identity_response: definition.fetch(:identity_response),
revoke_path: definition.fetch(:revoke_path),
keychain_service: definition.fetch(:keychain_service)
)
Expand Down
47 changes: 47 additions & 0 deletions test/cli_commands_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,53 @@ def test_time_off_status_uses_its_own_identity_endpoint_and_credential_source
assert_equal 'env', JSON.parse(stdout).dig('data', 'source')
end

def test_femini_status_uses_a_read_only_authentication_probe_without_returning_business_data
credentials = FakeCredentials.new(
credential: FeedMob::CLI::Credential.new(value: 'femini-bearer-token', source: 'keychain')
)
femini_client = FakeClient.new(
[FeedMob::CLI::HTTP::Response.new(
status: 200, headers: {}, data: { 'data' => [{ 'name' => 'Hidden' }] }
)]
)
use_runtime(
credentials:,
clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'femini' => femini_client }
)

stdout, = run_cli('femini', 'auth', 'status', '--json')

assert_equal(
{ method: :get, path: '/clients.json?name_cont=__feedmob_cli_auth_probe__', token: 'femini-bearer-token' },
femini_client.requests.fetch(0)
)
assert_equal(
{ 'service' => 'femini', 'authenticated' => true, 'source' => 'keychain' },
JSON.parse(stdout).fetch('data')
)
end

def test_femini_request_get_uses_the_femini_credential
credentials = FakeCredentials.new(
credential: FeedMob::CLI::Credential.new(value: 'femini-bearer-token', source: 'keychain')
)
femini_client = FakeClient.new(
[FeedMob::CLI::HTTP::Response.new(status: 200, headers: {}, data: { 'series' => [] })]
)
use_runtime(
credentials:,
clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'femini' => femini_client }
)

stdout, = run_cli('femini', 'request', 'get', '/daily_metrics.json', '--json')

assert_equal(
{ method: :get, path: '/daily_metrics.json', token: 'femini-bearer-token' },
femini_client.requests.fetch(0)
)
assert_equal({ 'series' => [] }, JSON.parse(stdout).dig('data', 'response'))
end

def test_pixel_logout_revokes_the_remote_token_then_deletes_local_keychain_value
credentials = FakeCredentials.new
pixel_client = FakeClient.new(
Expand Down
1 change: 1 addition & 0 deletions test/cli_help_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def test_top_level_help_lists_service_namespaces_and_doctor

assert_predicate status, :success?, stderr
assert_includes stdout, 'doctor'
assert_includes stdout, 'femini'
assert_includes stdout, 'pixel'
assert_includes stdout, 'time-off'
end
Expand Down
10 changes: 10 additions & 0 deletions test/credentials_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,14 @@ def test_store_and_delete_delegate_to_keychain
assert_equal [%w[pixel fmpat_secret]], keychain.writes
assert_equal ['pixel'], keychain.deletes
end

def test_femini_accepts_a_bearer_token_without_a_documented_prefix
service = FeedMob::CLI::Services.fetch('femini', env: {})
keychain = FakeKeychain.new
credentials = FeedMob::CLI::Credentials.new(env: {}, keychain:)

credentials.store(service, 'femini-bearer-token')

assert_equal [%w[femini femini-bearer-token]], keychain.writes
end
end
12 changes: 12 additions & 0 deletions test/services_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ def test_time_off_contract_stays_on_existing_api
assert_nil service.revoke_path
end

def test_femini_contract_uses_documented_bearer_authentication
service = FeedMob::CLI::Services.fetch('femini', env: {})

assert_equal 'Femini', service.label
assert_equal 'https://assistant.feedmob.ai', service.base_url
assert_equal 'FEEDMOB_FEMINI_TOKEN', service.token_env
assert_nil service.token_prefix
assert_equal '/clients.json?name_cont=__feedmob_cli_auth_probe__', service.identity_path
refute_predicate service, :identity_response
assert_nil service.revoke_path
end

def test_https_base_url_environment_override_is_normalized
service = FeedMob::CLI::Services.fetch(
'pixel',
Expand Down
Loading