diff --git a/README.md b/README.md index 01adbf1..2cabc96 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 +fm femini auth login [--token-stdin] +fm femini auth status +fm femini auth logout +fm femini request get ``` ## Installation @@ -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 @@ -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. @@ -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 diff --git a/lib/feedmob/cli.rb b/lib/feedmob/cli.rb index 65a6b43..84b3719 100644 --- a/lib/feedmob/cli.rb +++ b/lib/feedmob/cli.rb @@ -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 diff --git a/lib/feedmob/cli/commands/auth.rb b/lib/feedmob/cli/commands/auth.rb index 556ba76..4026842 100644 --- a/lib/feedmob/cli/commands/auth.rb +++ b/lib/feedmob/cli/commands/auth.rb @@ -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 @@ -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 diff --git a/lib/feedmob/cli/commands/base.rb b/lib/feedmob/cli/commands/base.rb index 775c2fc..b41ab7a 100644 --- a/lib/feedmob/cli/commands/base.rb +++ b/lib/feedmob/cli/commands/base.rb @@ -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 diff --git a/lib/feedmob/cli/commands/doctor.rb b/lib/feedmob/cli/commands/doctor.rb index 846b468..839bdb7 100644 --- a/lib/feedmob/cli/commands/doctor.rb +++ b/lib/feedmob/cli/commands/doctor.rb @@ -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, diff --git a/lib/feedmob/cli/commands/request.rb b/lib/feedmob/cli/commands/request.rb index 9c13232..e63e279 100644 --- a/lib/feedmob/cli/commands/request.rb +++ b/lib/feedmob/cli/commands/request.rb @@ -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 diff --git a/lib/feedmob/cli/credentials.rb b/lib/feedmob/cli/credentials.rb index ff6f6a5..85a4cb3 100644 --- a/lib/feedmob/cli/credentials.rb +++ b/lib/feedmob/cli/credentials.rb @@ -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', diff --git a/lib/feedmob/cli/registry.rb b/lib/feedmob/cli/registry.rb index 83dbb6a..9b00d7e 100644 --- a/lib/feedmob/cli/registry.rb +++ b/lib/feedmob/cli/registry.rb @@ -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 diff --git a/lib/feedmob/cli/service.rb b/lib/feedmob/cli/service.rb index b919a30..2cfbb62 100644 --- a/lib/feedmob/cli/service.rb +++ b/lib/feedmob/cli/service.rb @@ -9,6 +9,7 @@ module CLI :token_env, :token_prefix, :identity_path, + :identity_response, :revoke_path, :keychain_service ) diff --git a/lib/feedmob/cli/services.rb b/lib/feedmob/cli/services.rb index 4857421..67c123d 100644 --- a/lib/feedmob/cli/services.rb +++ b/lib/feedmob/cli/services.rb @@ -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' }, @@ -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 @@ -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) ) diff --git a/test/cli_commands_test.rb b/test/cli_commands_test.rb index 37276fe..e035709 100644 --- a/test/cli_commands_test.rb +++ b/test/cli_commands_test.rb @@ -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( diff --git a/test/cli_help_test.rb b/test/cli_help_test.rb index 91d1a21..929a44a 100644 --- a/test/cli_help_test.rb +++ b/test/cli_help_test.rb @@ -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 diff --git a/test/credentials_test.rb b/test/credentials_test.rb index fe6f649..1971cfa 100644 --- a/test/credentials_test.rb +++ b/test/credentials_test.rb @@ -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 diff --git a/test/services_test.rb b/test/services_test.rb index bc216bc..55c4480 100644 --- a/test/services_test.rb +++ b/test/services_test.rb @@ -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',